LANTERN purge: delete the superseded base/expedition shell (audit H1/H3/M5)

The 2026-08-06 audit found the shipping scene was still the abandoned
co-op-Hades game with LANTERN combat bolted on, and that a third of the
codebase was live code for a direction abandoned on 2026-07-13. Operator
chose deletion over freezing: "everything is saved in source control if
needed. I want the project to be clean."

DELETED (~140 source files, Scripts 335->231, Tests 77->43):
- Enemy variants + boss (H3). ChargerAuthoring / SpitterAuthoring /
  SwarmerAuthoring were attached to ZERO prefabs, so LungeState /
  SpitterState / SwarmerTag were never baked: ~272 lines of Bursted AI
  passes, BossAISystem (261 lines) and the whole MixBands escalation
  curve could not match a single chunk at runtime, while 734 lines of
  green tests certified them. Both shipping enemy prefabs were already
  byte-identical in stats.
- Run/room lifecycle: RunDirector FSM, RunInfo/RunMap/RoomPlan/RoomTag,
  route select, portal interact, ready-check, room field/teardown.
- Meta shop, prep loadout, boons (incl. KillRewardSystem and
  DashTrailDamageSystem, which existed only to serve boon flags).
- Build palette + structures, shared storage, inventory/equipment
  (already recorded PAUSED in CLAUDE.md).
- The HUD panels driving all of the above (HudSystem 1168 -> 610).

KEPT deliberately: BaseGridMath + BaseAnchor (8 systems use PlotCenter
for spawn rings, respawn and dynamic light), the resource ledger +
StorageMath, the save system, region/relevancy. Three of these were in
the delete set until I checked their consumers — worth remembering that
the file-level manifest was wrong about them.

Also folds in audit finding M5: PlayerClass was a second, server-only
copy of the byte FrameId already replicates. It existed for the meta
shop; with that gone, FrameId is the single frame identity.

Harvest is now single-sink (ledger). HarvestMath keeps its shape so
LANTERN's carried-vs-banked cargo split lands in one place, not two.

295/295 EditMode green, zero compile errors. Subscene re-bake and Play
validation follow in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 12:59:39 -07:00
parent 6a412fe3e7
commit 62e48a3b0b
304 changed files with 260 additions and 14591 deletions
@@ -1,219 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
using System.Collections.Generic;
namespace ProjectM.Tests
{
/// <summary>
/// Pins the two-channel boon lifecycle (Phase 1.7 table). <see cref="BoonApplySystem"/>: a valid STAT pick appends
/// exactly ONE boon-band <see cref="StatModifier"/> and clears Pending; a MECHANIC-CHANGER pick mutates
/// <see cref="BoonEffects"/> (no StatModifier row); out-of-range / not-pending / closed-lifecycle picks are rejected;
/// the grace auto-pick deals Option0. The RunDirector Returning-edge strip: every boon-band StatModifier dies,
/// BoonEffects is zeroed, the Frenzy timed row is removed from BOTH buffers, and class/meta/equip bands survive.
/// New default table ids: 1 Piercing (effect), 4 Detonating (effect), 9 Executioner (Damage +50%),
/// 10 Titan (MaxHealth +60), 11 Fleet Foot (MoveSpeed +18%).
/// </summary>
public class BoonApplyTests
{
const uint T0 = 3000;
readonly List<World> _worlds = new();
[TearDown]
public void Cleanup()
{
foreach (var w in _worlds) if (w.IsCreated) w.Dispose();
_worlds.Clear();
}
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);
}
// Defaults to STAT ids so an accepted pick appends a StatModifier row (o1 = 11 Fleet Foot).
static Entity MakePicker(EntityManager em, int netId, byte o0 = 9, byte o1 = 11, byte o2 = 10)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), typeof(RegionTag),
typeof(BoonEffects));
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 ValidStatPick_AppendsBoonBandRow_AndClearsPending()
{
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
using (world)
{
var em = world.EntityManager;
var player = MakePicker(em, 1);
SendPick(em, 1, index: 1); // Option1 = id 11 (Fleet Foot, MoveSpeed +18%)
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.18f, 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");
}
}
[Test]
public void EffectPick_MutatesBoonEffects_AppendsNoStatRow()
{
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
using (world)
{
var em = world.EntityManager;
var player = MakePicker(em, 1, o0: 1); // Option0 = id 1 (Piercing Shots — a mechanic-changer)
SendPick(em, 1, index: 0);
group.Update();
Assert.AreEqual(0, BoonRows(em, player), "a mechanic-changer appends NO StatModifier row");
Assert.AreEqual(1, em.GetComponentData<BoonEffects>(player).Pierce, "Pierce incremented");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "pick consumed");
Assert.AreEqual(0u, em.GetComponentData<RunRuntime>(dir).BoonPickCounter, "no band row → counter unchanged");
}
}
[Test]
public void Rejects_NotPending_ClosedLifecycle_KeepsBufferClean()
{
// Not pending.
var (w1, g1, d1, c1) = MakeWorld(RunLifecycle.RoomReward);
_worlds.Add(w1);
var p1 = MakePicker(w1.EntityManager, 1);
w1.EntityManager.SetComponentData(p1, new BoonOffer { Pending = 0, Option0 = 9 });
SendPick(w1.EntityManager, 1, 0);
g1.Update();
Assert.AreEqual(0, BoonRows(w1.EntityManager, p1), "not-pending pick rejected");
// Lifecycle closed (Returning): the straggler pick dies BEFORE any strip could be out-run (D-F4).
var (w2, g2, d2, c2) = MakeWorld(RunLifecycle.Returning);
_worlds.Add(w2);
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");
}
[Test]
public void GraceElapsed_AutoPicksOption0_ForPendingExpeditionPlayers()
{
var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward);
using (world)
{
var em = world.EntityManager;
var afk = MakePicker(em, 1, o0: 10); // Option0 = id 10 (Titan's Vigor, +60 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");
}
}
[Test]
public void ReturningStrip_KillsBoonBand_ZeroesEffects_SparesClassMetaEquip()
{
// Drive the REAL RunDirectorSystem Returning edge over a player carrying all four StatModifier bands
// PLUS mechanic-changer BoonEffects + a Frenzy timed row (StatModifier + TimedModifier).
var world = new World("BoonStripTest");
using (world)
{
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), typeof(BoonEffects));
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(player, LocalTransform.Identity);
em.SetComponentData(player, new BoonOffer { Pending = 1, Option0 = 1 });
em.SetComponentData(player, new BoonEffects { Pierce = 2, Flags = BoonFlag.Frenzy });
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 = 1, Op = 2, Value = -0.3f, SourceId = Tuning.FrenzySourceId }); // Frenzy (boon band top)
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
var timed = em.AddBuffer<TimedModifier>(player);
timed.Add(new TimedModifier { SourceId = Tuning.FrenzySourceId, UntilTick = T0 + 100 });
group.Update(); // Returning: strip + bank + home -> Staging
var after = em.GetBuffer<StatModifier>(player);
Assert.AreEqual(3, after.Length, "both boon-band rows (incl. Frenzy) stripped, 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.GetBuffer<TimedModifier>(player).Length, "Frenzy timed row stripped");
Assert.AreEqual(default(BoonEffects), em.GetComponentData<BoonEffects>(player), "mechanic-changer effects zeroed");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(player).Pending, "straggler offer zeroed");
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: f703eba535e33a3489b833c59cbd3803
@@ -1,145 +0,0 @@
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, default(BoonEffects), 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, default(BoonEffects), 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");
using (world)
{
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), typeof(BoonEffects));
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)");
}
}
static byte FamilyOf(ref BoonCatalogBlob pool, byte id)
{
int idx = BoonMath.FindDef(ref pool, id);
return idx >= 0 ? pool.Defs[idx].Family : (byte)0;
}
[Test]
public void PickBoons_NeverOffersTwoSameFamily_InOneDeal()
{
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 += 3)
{
BoonMath.PickBoons(seed, classId, default(BoonEffects), ref pool, out byte a0, out byte a1, out byte a2);
byte f0 = FamilyOf(ref pool, a0), f1 = FamilyOf(ref pool, a1), f2 = FamilyOf(ref pool, a2);
Assert.AreNotEqual(f0, f1, "dominated-offer protection: no two same-family options in one deal");
Assert.AreNotEqual(f1, f2, "dominated-offer protection: no two same-family options in one deal");
Assert.AreNotEqual(f0, f2, "dominated-offer protection: no two same-family options in one deal");
}
blob.Dispose();
}
[Test]
public void PickBoons_ExcludesOwnedNonStackingFlag()
{
var blob = BoonCatalogData.BuildDefault(Allocator.Temp);
ref var pool = ref blob.Value;
var owned = new BoonEffects { Flags = BoonFlag.DashTrail }; // already own Blade Dash (id 5, both classes)
for (byte classId = 0; classId <= 1; classId++)
for (uint seed = 1; seed < 300; seed += 3)
{
BoonMath.PickBoons(seed, classId, owned, ref pool, out byte a0, out byte a1, out byte a2);
Assert.IsFalse(a0 == 5 || a1 == 5 || a2 == 5, "an owned non-stacking flag boon (Blade Dash) is never re-offered");
}
blob.Dispose();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1e10416fe4cd405479a526eddd91bc1f
@@ -1,159 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode coverage for the server-only BossAISystem (the sole boss mover/attacker, previously
/// Play-only). Exercises phase gating, expedition-only targeting, the telegraphed radial-slam AoE + its radius
/// boundary, the B4 lunge-vs-slam windup disambiguation (a naive shared-windup reuse would slam on a lunge
/// elapse), and knockback immunity. Values are pinned to Tuning.Boss* so the tests track tuning, not literals.
/// No PhysicsWorldSingleton is created -> sweep=false -> the boss moves/lands unswept (headless-safe).
/// </summary>
public class BossAISystemTests
{
static Entity MakeBoss(EntityManager em, float3 pos, float cur, float max,
byte pending = 0, uint windup = 0, uint slamReady = 0, uint lungeReady = 0, uint knockUntil = 0)
{
var e = em.CreateEntity(typeof(EnemyTag), typeof(BossState), typeof(LocalTransform), typeof(EnemyStats),
typeof(Health), typeof(AttackWindup), typeof(KnockbackState), typeof(LungeState));
em.SetComponentData(e, LocalTransform.FromPosition(pos));
em.SetComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 2f, AttackDamage = 10f, AttackCooldownTicks = 30 });
em.SetComponentData(e, new Health { Current = cur, Max = max });
em.SetComponentData(e, new BossState { Phase = 1, PendingAttack = pending, SlamReadyTick = slamReady, LungeReadyTick = lungeReady });
em.SetComponentData(e, new AttackWindup { WindUpUntilTick = windup });
em.SetComponentData(e, new KnockbackState { UntilTick = knockUntil });
return e;
}
static Entity MakePlayer(EntityManager em, float3 pos, byte region = RegionId.Expedition, float hp = 100f)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(RegionTag), typeof(LocalTransform), typeof(Health), typeof(DamageEvent));
em.SetComponentData(e, new RegionTag { Region = region });
em.SetComponentData(e, LocalTransform.FromPosition(pos));
em.SetComponentData(e, new Health { Current = hp, Max = hp });
return e;
}
[Test]
public void Phase_One_When_Above_Half_HP()
{
var (world, group) = TestWorld.Make<BossAISystem>("Boss_P1", tick: 200, server: true);
using (world)
{
var em = world.EntityManager;
var boss = MakeBoss(em, float3.zero, cur: 60f, max: 100f);
MakePlayer(em, new float3(3f, 0f, 0f));
group.Update();
Assert.AreEqual((byte)1, em.GetComponentData<BossState>(boss).Phase, "Above the phase-2 fraction -> phase 1.");
}
}
[Test]
public void Phase_Two_At_Or_Below_Half_HP()
{
var (world, group) = TestWorld.Make<BossAISystem>("Boss_P2", tick: 200, server: true);
using (world)
{
var em = world.EntityManager;
float cur = 100f * Tuning.BossPhase2HealthFraction; // exactly the boundary
var boss = MakeBoss(em, float3.zero, cur: cur, max: 100f);
MakePlayer(em, new float3(3f, 0f, 0f));
group.Update();
Assert.AreEqual((byte)2, em.GetComponentData<BossState>(boss).Phase, "At/below the fraction -> phase 2.");
}
}
[Test]
public void No_Living_Expedition_Target_Leaves_Boss_Idle()
{
var (world, group) = TestWorld.Make<BossAISystem>("Boss_NoTgt", tick: 200, server: true);
using (world)
{
var em = world.EntityManager;
var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f);
MakePlayer(em, new float3(3f, 0f, 0f), region: RegionId.Base); // wrong region
MakePlayer(em, new float3(4f, 0f, 0f), region: RegionId.Expedition, hp: 0f); // dead
group.Update();
Assert.AreEqual(float3.zero, em.GetComponentData<LocalTransform>(boss).Position, "No valid target -> the boss does not move.");
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(boss).WindUpUntilTick, "No target -> no telegraph.");
}
}
[Test]
public void Slam_Lands_Radial_AoE_On_Windup_Elapse()
{
var (world, group) = TestWorld.Make<BossAISystem>("Boss_Slam", tick: 200, server: true);
using (world)
{
var em = world.EntityManager;
var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 0, windup: 100); // 100 <= 200 -> elapsed
var player = MakePlayer(em, new float3(1f, 0f, 0f)); // inside slam radius
group.Update();
var dmg = em.GetBuffer<DamageEvent>(player);
Assert.AreEqual(1, dmg.Length, "A player inside the slam radius takes one hit on landing.");
Assert.AreEqual(Tuning.BossSlamDamage, dmg[0].Amount, 1e-3f);
Assert.AreEqual(-1, dmg[0].SourceNetworkId, "Slam damage is sourced from the boss/environment (-1).");
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(boss).WindUpUntilTick, "Windup cleared after landing.");
}
}
[Test]
public void Slam_Misses_A_Player_Outside_The_Radius()
{
var (world, group) = TestWorld.Make<BossAISystem>("Boss_SlamMiss", tick: 200, server: true);
using (world)
{
var em = world.EntityManager;
MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 0, windup: 100);
var player = MakePlayer(em, new float3(Tuning.BossSlamRadius + 1f, 0f, 0f)); // just outside
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "Outside the slam radius takes no hit.");
}
}
[Test]
public void Lunge_Windup_Elapse_Commits_Travel_And_Does_Not_Slam()
{
var (world, group) = TestWorld.Make<BossAISystem>("Boss_Lunge", tick: 200, server: true);
using (world)
{
var em = world.EntityManager;
var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, pending: 1, windup: 100); // PendingAttack=1 => lunge
var player = MakePlayer(em, new float3(1f, 0f, 0f)); // inside slam radius, but a LUNGE must not slam
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "A lunge elapse must NOT deal slam damage (B4 disambiguation).");
Assert.AreEqual(Tuning.BossLungeSpeed, em.GetComponentData<LungeState>(boss).Speed, 1e-3f, "Lunge travel committed.");
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(boss).WindUpUntilTick, "Windup cleared on lunge commit.");
}
}
[Test]
public void Knockback_Residual_Is_Zeroed_Boss_Is_Immune()
{
var (world, group) = TestWorld.Make<BossAISystem>("Boss_Knock", tick: 200, server: true);
using (world)
{
var em = world.EntityManager;
var boss = MakeBoss(em, float3.zero, cur: 100f, max: 100f, knockUntil: 999u);
MakePlayer(em, new float3(3f, 0f, 0f));
group.Update();
Assert.AreEqual(0u, em.GetComponentData<KnockbackState>(boss).UntilTick, "The boss is knockback-immune: residual is zeroed.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 407138571a76c6f4f821fcbde92327a5
@@ -1,151 +0,0 @@
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 the server-only <see cref="BuildPlaceSystem"/> — the RPC structure-placement
/// handler. A bare world is seeded with StructureCatalog (+ a Turret entry referencing a Prefab-tagged prefab),
/// BaseAnchor, ResourceLedger (+ Ore), NetworkTime, and synthetic BuildPlaceRequest + ReceiveRpcCommandRequest
/// entities. The headline case is co-op atomicity: two same-tick requests for one cell must place EXACTLY one
/// structure and withdraw the cost ONCE (the in-place commit). Also pins cost/plot validation and request cleanup.
/// </summary>
public class BuildPlaceSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, int oreCount)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<BuildPlaceSystem>());
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(300) });
var anchor = em.CreateEntity(typeof(BaseAnchor));
em.SetComponentData(anchor, new BaseAnchor
{
AnchorPos = new float3(5, 0, 5),
GridOrigin = new float3(0, 0, 0),
CellSize = 2f,
GridDims = new int2(5, 5),
});
// Turret prefab: LocalTransform (looked up for placement) + PlacedStructure (SetComponent target on the
// clone) + a real Prefab tag so it is excluded from the live-structure occupancy scan.
var prefab = em.CreateEntity(typeof(LocalTransform), typeof(PlacedStructure));
em.AddComponent<Prefab>(prefab);
var catalogE = em.CreateEntity(typeof(StructureCatalog));
var catalog = em.AddBuffer<StructureCatalogEntry>(catalogE);
catalog.Add(new StructureCatalogEntry
{
Type = StructureType.Turret, Prefab = prefab, CostResourceId = ResourceId.Ore, CostAmount = 10,
});
var ledgerE = em.CreateEntity(typeof(ResourceLedger));
var ledger = em.AddBuffer<StorageEntry>(ledgerE);
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = oreCount });
return (world, group);
}
static void MakeBuildRequest(EntityManager em, byte type, int cellX, int cellZ)
{
var e = em.CreateEntity();
em.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ });
em.AddComponentData(e, default(ReceiveRpcCommandRequest));
}
static int StructureCount(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(PlacedStructure));
return q.CalculateEntityCount();
}
static int OreCount(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(ResourceLedger));
var ledger = em.GetBuffer<StorageEntry>(q.GetSingletonEntity());
for (int i = 0; i < ledger.Length; i++)
if (ledger[i].ItemId == ResourceId.Ore) return ledger[i].Count;
return 0;
}
[Test]
public void Valid_Request_Places_Structure_Withdraws_Cost_And_Destroys_Request()
{
var (world, group) = MakeWorld("BuildValid", oreCount: 50);
using (world)
{
var em = world.EntityManager;
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
group.Update();
Assert.AreEqual(1, StructureCount(em), "A valid request places exactly one structure.");
Assert.AreEqual(40, OreCount(em), "The build cost (10) is withdrawn from the ledger.");
using var reqQ = em.CreateEntityQuery(typeof(BuildPlaceRequest));
Assert.AreEqual(0, reqQ.CalculateEntityCount(), "The handled request is destroyed.");
}
}
[Test]
public void Two_Same_Cell_Requests_Place_Only_One_And_Withdraw_Once()
{
var (world, group) = MakeWorld("BuildAtomic", oreCount: 50);
using (world)
{
var em = world.EntityManager;
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
group.Update();
Assert.AreEqual(1, StructureCount(em),
"Two same-tick requests for one cell place exactly one structure (co-op atomicity).");
Assert.AreEqual(40, OreCount(em), "The cost is withdrawn exactly once, not twice.");
}
}
[Test]
public void Insufficient_Resources_Places_Nothing()
{
var (world, group) = MakeWorld("BuildPoor", oreCount: 5);
using (world)
{
var em = world.EntityManager;
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
group.Update();
Assert.AreEqual(0, StructureCount(em), "A request that can't afford the cost places nothing.");
Assert.AreEqual(5, OreCount(em), "The ledger is untouched on an unaffordable request.");
}
}
[Test]
public void Out_Of_Plot_Cell_Places_Nothing()
{
var (world, group) = MakeWorld("BuildOOB", oreCount: 50);
using (world)
{
var em = world.EntityManager;
MakeBuildRequest(em, StructureType.Turret, cellX: 99, cellZ: 99);
group.Update();
Assert.AreEqual(0, StructureCount(em), "An out-of-plot cell places nothing.");
Assert.AreEqual(50, OreCount(em), "No cost is withdrawn for an illegal placement.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 69248c6e19368b246a8aa8b151a8f7b0
@@ -1,55 +0,0 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Mathematics;
namespace ProjectM.Tests
{
/// <summary>
/// Pure tests for <see cref="BuildPreviewMath"/> — the client build-ghost validity (in-plot, unoccupied,
/// affordable) that mirrors the server's authoritative BuildPlaceSystem check, colouring the ground ghost
/// green (valid) vs red (the first failing reason).
/// </summary>
public class BuildPreviewMathTests
{
static BaseAnchor Anchor() => new BaseAnchor
{
AnchorPos = new float3(0, 0, 0),
GridOrigin = new float3(0, 0, 0),
CellSize = 1f,
GridDims = new int2(8, 8),
};
[Test]
public void InPlot_Unoccupied_Affordable_IsValid()
{
Assert.AreEqual(BuildPreviewMath.Valid,
BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 50, cost: 20));
}
[Test]
public void OutOfPlot_Reported_First()
{
Assert.AreEqual(BuildPreviewMath.OutOfPlot,
BuildPreviewMath.Evaluate(Anchor(), new int2(99, 0), occupied: true, have: 0, cost: 999),
"Out-of-plot is reported before occupancy / cost.");
Assert.AreEqual(BuildPreviewMath.OutOfPlot,
BuildPreviewMath.Evaluate(Anchor(), new int2(-1, 3), occupied: false, have: 50, cost: 10));
}
[Test]
public void Occupied_Cell_IsBlocked()
{
Assert.AreEqual(BuildPreviewMath.Occupied,
BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: true, have: 50, cost: 10));
}
[Test]
public void Unaffordable_When_Have_Below_Cost_Exact_Funds_Ok()
{
Assert.AreEqual(BuildPreviewMath.Unaffordable,
BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 5, cost: 20));
Assert.AreEqual(BuildPreviewMath.Valid,
BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 20, cost: 20));
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 2d3f16d92d3045044a9efd5545320111
@@ -1,192 +0,0 @@
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 the MC-1 Charger branch in EnemyAISystem. A Husk variant baked with
/// LungeState commits to a fixed-direction lunge on wind-up elapse (UNLIKE the Grunt, it does NOT cancel when
/// the target leaves range — the commit is the punishable tell), deals contact damage if it connects, and
/// staggers (extends EnemyAttackCooldown + clears the lunge + opens a telemetry whiff window) if it overshoots
/// or wall-stops. Knockback cancels an in-flight lunge so EnemyAISystem stays the SOLE Position writer.
/// </summary>
public class ChargerTests
{
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<EnemyAISystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
static Entity MakePlayer(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponent<PlayerTag>(e);
em.AddBuffer<DamageEvent>(e);
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
return e;
}
static Entity MakeCharger(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 12f, AttackCooldownTicks = 36 });
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 });
em.AddComponentData(e, new KnockbackState());
em.AddComponentData(e, new AttackWindup());
em.AddComponentData(e, new LungeState());
em.AddComponent<IsLunging>(e);
em.SetComponentEnabled<IsLunging>(e, false); // baked DISABLED on the real Charger (spawns not-lunging)
em.AddComponent<EnemyTag>(e);
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
return e;
}
[Test]
public void Commit_Fires_Even_When_Target_Left_Range()
{
var (world, group) = MakeWorld("ChargerCommit", 200);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(10, 1, 0)); // far out of AttackRange (1.6)
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick
group.Update(); // tick 200
var lunge = em.GetComponentData<LungeState>(charger);
Assert.AreNotEqual(0u, lunge.UntilTick, "Charger commits the lunge even with the target out of range (no cancel-on-leave-range).");
Assert.Greater(lunge.Dir.x, 0.5f, "Lunge direction is locked toward the target at commit (+X).");
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(charger).WindUpUntilTick, "The wind-up clears on commit.");
}
}
[Test]
public void Overshoot_Whiff_Staggers_And_Opens_A_Punish_Window()
{
var (world, group) = MakeWorld("ChargerWhiff", 206);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(-10, 1, 0)); // player is behind; the lunge goes +X, never connects
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring lunge
em.CreateEntity(typeof(DevTelemetry)); // so the whiff telemetry increment is observable
group.Update(); // tick 206 > 205 -> lunge timer elapsed without landing -> overshoot whiff
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "A whiffed lunge is cleared.");
Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData<EnemyAttackCooldown>(charger).NextAttackTick,
"An overshoot whiff extends the attack cooldown by the stagger window (the punish window).");
using var tq = em.CreateEntityQuery(typeof(DevTelemetry));
Assert.AreEqual(1u, tq.GetSingleton<DevTelemetry>().ChargerWhiffWindowsOpened, "A whiff opens one telemetry punish window.");
Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData<LungeState>(charger).StaggerUntilTick,
"The whiff stamps the scoreable StaggerUntilTick window (ChargerWhiffPunishesLanded source).");
}
}
[Test]
public void Knockback_Cancels_An_InFlight_Lunge()
{
var (world, group) = MakeWorld("ChargerKnockback", 305);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(10, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 }); // mid-lunge +X
em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 }); // recoil -X
group.Update(); // tick 305: knockback (until 315) wins
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick,
"Knockback cancels the in-flight lunge (no two-writer contention on Position).");
Assert.Less(em.GetComponentData<LocalTransform>(charger).Position.x, 0f,
"The recoiling Charger moved along its knockback direction (-X), not its lunge direction.");
}
}
[Test]
public void Commit_Enables_IsLunging()
{
var (world, group) = MakeWorld("ChargerIsLungingCommit", 200);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(3, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick -> commit
Assert.IsFalse(em.IsComponentEnabled<IsLunging>(charger), "Charger spawns not-lunging (baked DISABLED).");
group.Update(); // tick 200: commit the lunge
Assert.AreNotEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "Sanity: the lunge committed.");
Assert.IsTrue(em.IsComponentEnabled<IsLunging>(charger),
"The replicated mid-lunge cue is ENABLED while a committed lunge is live (.WithPresent visits the disabled entity to write the bit).");
}
}
[Test]
public void Whiff_Disables_IsLunging()
{
var (world, group) = MakeWorld("ChargerIsLungingWhiff", 206);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(-10, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring
em.SetComponentEnabled<IsLunging>(charger, true); // was mid-lunge
group.Update(); // tick 206 > 205 -> overshoot whiff clears the lunge
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "Sanity: the whiffed lunge cleared.");
Assert.IsFalse(em.IsComponentEnabled<IsLunging>(charger), "The cue clears the tick the lunge ends (whiff).");
}
}
[Test]
public void Knockback_Disables_IsLunging()
{
var (world, group) = MakeWorld("ChargerIsLungingKnockback", 305);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(10, 1, 0));
var charger = MakeCharger(em, new float3(0, 1, 0));
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 });
em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 });
em.SetComponentEnabled<IsLunging>(charger, true); // mid-lunge before the knockback
group.Update(); // tick 305: knockback cancels the lunge (UntilTick -> 0) via the mid-body continue path
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "Sanity: knockback cancelled the lunge.");
Assert.IsFalse(em.IsComponentEnabled<IsLunging>(charger),
"The cue clears when knockback cancels the lunge (covers the mid-body continue exit path).");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c31affe7e592820448b105987c883868
@@ -1,94 +0,0 @@
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 tests for <see cref="DashTrailDamageSystem"/> (Phase 1.7 Blade Dash). A dashing player with the
/// boon damages a nearby enemy ONCE per dash (StartTick-keyed dedup survives a re-tick); a fresh dash hits again;
/// no boon → no damage.
/// </summary>
public class DashTrailDamageSystemTests
{
static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld(uint tick)
{
var world = new World("DashTrailTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<DashTrailDamageSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
em.SetComponentData(em.CreateEntity(typeof(NetworkTime)), new NetworkTime { ServerTick = new NetworkTick(tick) });
return (world, group, em);
}
static void SetTick(EntityManager em, uint tick)
{
using var q = em.CreateEntityQuery(typeof(NetworkTime));
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static Entity MakeDasher(EntityManager em, byte flags, uint startTick, uint iframeUntil)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(BoonEffects), typeof(DashTrailState));
em.AddComponentData(e, LocalTransform.FromPosition(new float3(0f, 0f, 0f)));
em.AddComponentData(e, new DashState { Dir = new float2(1f, 0f), StartTick = startTick, IFrameUntilTick = iframeUntil, RecoverUntilTick = iframeUntil + 9 });
em.AddComponent<Simulate>(e); // enabled by default
em.SetComponentData(e, new GhostOwner { NetworkId = 1 });
em.SetComponentData(e, new BoonEffects { Flags = flags });
return e;
}
static Entity MakeEnemy(EntityManager em, float3 pos)
{
var e = em.CreateEntity(typeof(EnemyTag));
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new HitRadius { Value = 0.5f });
em.AddComponentData(e, new Health { Current = 60f, Max = 60f });
em.AddBuffer<DamageEvent>(e);
return e;
}
[Test]
public void BladeDash_DamagesNearbyEnemy_OncePerDash_ReHitsOnNextDash()
{
var (world, group, em) = MakeWorld(100);
using (world)
{
var player = MakeDasher(em, BoonFlag.DashTrail, startTick: 100, iframeUntil: 112);
var enemy = MakeEnemy(em, new float3(1f, 0f, 0f)); // within 1.6 + 0.5
group.Update(); // tick 100, dashing
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "enemy in the dash path takes one hit");
group.Update(); // same tick + same StartTick -> dedup, no second hit
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "no re-hit within the same dash");
// A fresh dash (new StartTick) resets the dedup set -> the enemy can be hit again.
SetTick(em, 130);
em.SetComponentData(player, new DashState { Dir = new float2(1f, 0f), StartTick = 130, IFrameUntilTick = 142, RecoverUntilTick = 151 });
group.Update();
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(enemy).Length, "a fresh dash hits the enemy again");
}
}
[Test]
public void NoBoon_NoDamage()
{
var (world, group, em) = MakeWorld(100);
using (world)
{
MakeDasher(em, flags: 0, startTick: 100, iframeUntil: 112); // no DashTrail flag
var enemy = MakeEnemy(em, new float3(1f, 0f, 0f));
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "no Blade Dash boon -> no trail damage");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7603c5c6b91bb854d8b88739bdb6f4b1
@@ -1,132 +0,0 @@
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>
/// MC-2 tests for the hostile Spitter projectile systems (server-only, plain SimulationSystemGroup):
/// EnemyProjectileMoveSystem integrates + writes LastStep; EnemyProjectileDamageSystem swept-hit-tests players +
/// structures, REGION-FILTERED, appending a DamageEvent + destroying the spit at-most-once. Covers the two
/// review-mandated regressions: swept anti-TUNNELLING (a per-tick step bigger than the target radius still
/// registers) and the cross-region damage guard (an Expedition spit must not damage a Base target on its path).
/// </summary>
public class EnemyProjectileTests
{
static void SetTick(World w, uint tick)
{
var em = w.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World, SimulationSystemGroup) MoveWorld()
{
var w = new World("EnemyProjMove");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyProjectileMoveSystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f));
return (w, g);
}
static (World, SimulationSystemGroup) DamageWorld()
{
var w = new World("EnemyProjDmg");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyProjectileDamageSystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f));
SetTick(w, 200);
return (w, g);
}
static Entity MakeSpit(EntityManager em, float3 pos, float2 dir, float speed, float range, byte region, float lastStep = 0f, float damage = 10f)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new EnemyProjectile { Direction = dir, Speed = speed, Damage = damage, Range = range, DistanceTravelled = 0f, LastStep = lastStep, Region = region });
return e;
}
static Entity MakePlayerTarget(EntityManager em, float3 pos, byte region, float radius = 0.6f)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponentData(e, new HitRadius { Value = radius });
em.AddComponentData(e, new RegionTag { Region = region });
em.AddBuffer<DamageEvent>(e);
em.AddComponent<PlayerTag>(e);
return e;
}
[Test]
public void Move_IntegratesAndStoresLastStep()
{
var (w, g) = MoveWorld();
using (w)
{
var em = w.EntityManager;
var spit = MakeSpit(em, new float3(0, 1, 0), new float2(1, 0), 10f, 5f, RegionId.Base);
g.Update(); // dt 0.1 * speed 10 = step 1
var p = em.GetComponentData<EnemyProjectile>(spit);
Assert.AreEqual(1f, p.LastStep, 1e-4f, "LastStep = Speed*dt (for the swept segment)");
Assert.AreEqual(1f, p.DistanceTravelled, 1e-4f);
Assert.AreEqual(1f, em.GetComponentData<LocalTransform>(spit).Position.x, 1e-4f, "moved along +X");
}
}
[Test]
public void Damage_HitsSameRegionPlayer_DestroysAtMostOnce()
{
var (w, g) = DamageWorld();
using (w)
{
var em = w.EntityManager;
var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base);
var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Base, lastStep: 1f);
g.Update();
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(player).Length, "same-region player takes the hit");
Assert.IsFalse(em.Exists(spit), "the spit is consumed on hit");
}
}
[Test]
public void Damage_RegionFilter_ExpeditionSpitSparesBasePlayer()
{
var (w, g) = DamageWorld();
using (w)
{
var em = w.EntityManager;
var basePlayer = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base);
var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Expedition, lastStep: 1f);
g.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(basePlayer).Length, "cross-region spit must NOT damage an off-region player");
Assert.IsTrue(em.Exists(spit), "and it is not consumed by an off-region target");
}
}
[Test]
public void Damage_SweptSegment_NoTunnelThroughSmallTarget()
{
var (w, g) = DamageWorld();
using (w)
{
var em = w.EntityManager;
// target radius 0.5 at x=5; spit now at x=10 but stepped 8 this tick (start x=2) -> segment [2..10] crosses x=5.
var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base, radius: 0.5f);
var spit = MakeSpit(em, new float3(10, 1, 0), new float2(1, 0), 80f, 50f, RegionId.Base, lastStep: 8f);
g.Update();
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(player).Length,
"swept segment hits even when the per-tick step exceeds the target radius (no tunnelling)");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e63d6c6d98027f248be3fc163961ca95
@@ -1,279 +0,0 @@
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>
/// Plain-Entities EditMode tests for the server-only <see cref="EquipSystem"/>. Seeds a player
/// (GhostOwner + PlayerTag + InventorySlot + EquipmentSlot[4 rows] + StatModifier), an inline-built
/// ItemDatabase singleton, a mock connection, and an Equip/Unequip RPC. Weapons are STAT-STICKS
/// (LANTERN purge: the old weapon->AbilityRef grant is deleted; abilities live in the socket kit).
/// Pins: weapon-equip adds the slot-tagged mod + moves the item bag->slot; unequip reverses;
/// equip-over-occupied swaps the old item back; a full-bag swap is rejected with no item loss;
/// non-equippable / absent / unresolvable-connection requests no-op (request still consumed);
/// the unequip strip removes ONLY the slot sentinel, leaving foreign-SourceId mods (pickup 0u, upgrade) intact.
/// </summary>
public class EquipSystemTests
{
const ushort WeaponA = 100, WeaponB = 101, GearArmor = 110, Ore = 2;
BlobAssetReference<ItemDatabaseBlob> _blob;
[TearDown]
public void TearDown()
{
if (_blob.IsCreated) _blob.Dispose();
_blob = default;
}
static ItemModSpec NoMod() => new ItemModSpec { Target = 255 };
static ItemDefBlob Mk(ushort id, byte slot, ItemModSpec m0)
{
int stackMax = slot <= EquipSlotId.Tool ? 1 : 999;
return new ItemDefBlob
{
ItemId = id, Category = 0, Tier = 0, StackMax = stackMax,
EquipSlot = slot,
Mod0 = m0, Mod1 = NoMod(), Mod2 = NoMod(), Mod3 = NoMod(),
};
}
(World world, SimulationSystemGroup group) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<EquipSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<ItemDatabaseBlob>();
var arr = builder.Allocate(ref root.Items, 4);
arr[0] = Mk(WeaponA, EquipSlotId.Weapon, new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 5f });
arr[1] = Mk(WeaponB, EquipSlotId.Weapon, new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 9f });
arr[2] = Mk(GearArmor, EquipSlotId.Armor, new ItemModSpec { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentAdd, Value = 0.1f });
arr[3] = Mk(Ore, EquipSlotId.None, NoMod());
_blob = builder.CreateBlobAssetReference<ItemDatabaseBlob>(Allocator.Persistent);
builder.Dispose();
var dbE = em.CreateEntity(typeof(ItemDatabase));
em.SetComponentData(dbE, new ItemDatabase { Value = _blob });
return (world, group);
}
static Entity MakeConnection(EntityManager em, int networkId)
{
var e = em.CreateEntity();
em.AddComponentData(e, new NetworkId { Value = networkId });
return e;
}
static Entity MakePlayer(EntityManager em, int networkId, params (ushort id, int count)[] bagItems)
{
var e = em.CreateEntity();
em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
em.AddComponent<PlayerTag>(e);
var bag = em.AddBuffer<InventorySlot>(e);
foreach (var it in bagItems) bag.Add(new InventorySlot { ItemId = it.id, Count = it.count });
var slots = em.AddBuffer<EquipmentSlot>(e);
for (int s = 0; s < EquipSlotId.Count; s++) slots.Add(new EquipmentSlot { ItemId = 0 });
em.AddBuffer<StatModifier>(e);
return e;
}
static void MakeEquip(EntityManager em, ushort itemId, Entity conn)
{
var e = em.CreateEntity();
em.AddComponentData(e, new EquipRequest { ItemId = itemId });
em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn });
}
static void MakeUnequip(EntityManager em, byte slot, Entity conn)
{
var e = em.CreateEntity();
em.AddComponentData(e, new UnequipRequest { Slot = slot });
em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn });
}
static ushort Slot(EntityManager em, Entity p, byte slot) => em.GetBuffer<EquipmentSlot>(p)[slot].ItemId;
static int Bag(EntityManager em, Entity p, ushort id) => InventoryMath.CountOf(em.GetBuffer<InventorySlot>(p), id);
static int RequestsLeft(EntityManager em) { using var q = em.CreateEntityQuery(typeof(ReceiveRpcCommandRequest)); return q.CalculateEntityCount(); }
static int SlotModCount(EntityManager em, Entity p, byte slot)
{
var mods = em.GetBuffer<StatModifier>(p);
uint sid = Tuning.EquipSourceIdBase + (uint)slot;
int c = 0;
for (int i = 0; i < mods.Length; i++) if (mods[i].SourceId == sid) c++;
return c;
}
static int ModCountBySource(EntityManager em, Entity p, uint sourceId)
{
var mods = em.GetBuffer<StatModifier>(p);
int c = 0;
for (int i = 0; i < mods.Length; i++) if (mods[i].SourceId == sourceId) c++;
return c;
}
[Test]
public void Equip_Weapon_Adds_Mod_Moves_Item()
{
var (world, group) = MakeWorld("EquipWeapon");
using (world)
{
var em = world.EntityManager;
var conn = MakeConnection(em, 1);
var player = MakePlayer(em, 1, (WeaponA, 1));
MakeEquip(em, WeaponA, conn);
group.Update();
Assert.AreEqual(WeaponA, Slot(em, player, EquipSlotId.Weapon), "The weapon occupies the Weapon slot.");
Assert.AreEqual(0, Bag(em, player, WeaponA), "The weapon left the bag.");
Assert.AreEqual(1, SlotModCount(em, player, EquipSlotId.Weapon), "The weapon's mod is tagged the weapon-slot sentinel.");
Assert.AreEqual(0, RequestsLeft(em), "The request is consumed.");
}
}
[Test]
public void Unequip_Weapon_Strips_Mods_Returns_Item()
{
var (world, group) = MakeWorld("UnequipWeapon");
using (world)
{
var em = world.EntityManager;
var conn = MakeConnection(em, 1);
var player = MakePlayer(em, 1, (WeaponA, 1));
MakeEquip(em, WeaponA, conn);
group.Update();
MakeUnequip(em, EquipSlotId.Weapon, conn);
group.Update();
Assert.AreEqual(0, Slot(em, player, EquipSlotId.Weapon), "The Weapon slot is empty.");
Assert.AreEqual(1, Bag(em, player, WeaponA), "The weapon is back in the bag.");
Assert.AreEqual(0, SlotModCount(em, player, EquipSlotId.Weapon), "The weapon's mod is stripped.");
}
}
[Test]
public void Equip_Over_Occupied_Swaps_Old_Item_Back()
{
var (world, group) = MakeWorld("EquipSwap");
using (world)
{
var em = world.EntityManager;
var conn = MakeConnection(em, 1);
var player = MakePlayer(em, 1, (WeaponA, 1), (WeaponB, 1));
MakeEquip(em, WeaponA, conn);
group.Update();
MakeEquip(em, WeaponB, conn);
group.Update();
Assert.AreEqual(WeaponB, Slot(em, player, EquipSlotId.Weapon), "Weapon B now occupies the slot.");
Assert.AreEqual(1, Bag(em, player, WeaponA), "Weapon A swapped back into the bag.");
Assert.AreEqual(0, Bag(em, player, WeaponB), "Weapon B left the bag.");
Assert.AreEqual(1, SlotModCount(em, player, EquipSlotId.Weapon), "Exactly weapon B's single mod remains (A's stripped).");
}
}
[Test]
public void Swap_With_Full_Bag_Is_Rejected_No_Item_Loss()
{
var (world, group) = MakeWorld("FullBagSwap");
using (world)
{
var em = world.EntityManager;
var conn = MakeConnection(em, 1);
// Pre-equip weapon A directly, then fill the bag completely (incl. weapon B). Equipping B must
// reject because the bag has no room to receive the swapped-out weapon A.
var player = MakePlayer(em, 1, (WeaponB, 1));
var preSlots = em.GetBuffer<EquipmentSlot>(player);
preSlots[EquipSlotId.Weapon] = new EquipmentSlot { ItemId = WeaponA };
var bag = em.GetBuffer<InventorySlot>(player);
for (int i = 0; bag.Length < Tuning.InventoryMaxSlots; i++)
bag.Add(new InventorySlot { ItemId = (ushort)(200 + i), Count = 1 });
MakeEquip(em, WeaponB, conn);
group.Update();
Assert.AreEqual(WeaponA, Slot(em, player, EquipSlotId.Weapon), "The occupied slot is unchanged (equip rejected).");
Assert.AreEqual(1, Bag(em, player, WeaponB), "Weapon B was NOT withdrawn — no item loss.");
Assert.AreEqual(0, RequestsLeft(em), "The request is still consumed.");
}
}
[Test]
public void Equip_NonEquippable_Or_Absent_Item_Is_NoOp()
{
var (world, group) = MakeWorld("NoOpEquip");
using (world)
{
var em = world.EntityManager;
var conn = MakeConnection(em, 1);
var player = MakePlayer(em, 1, (Ore, 5)); // carries a resource (EquipSlot=None) but no weapon
MakeEquip(em, Ore, conn); // not equippable
MakeEquip(em, WeaponA, conn); // not in the bag
group.Update();
Assert.AreEqual(0, Slot(em, player, EquipSlotId.Weapon), "Nothing equipped.");
Assert.AreEqual(5, Bag(em, player, Ore), "The resource is untouched.");
Assert.AreEqual(0, RequestsLeft(em), "Both requests are consumed.");
}
}
[Test]
public void Equip_From_Unresolvable_Connection_NoOp()
{
var (world, group) = MakeWorld("UnresolvedEquip");
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, 1, (WeaponA, 1));
MakeEquip(em, WeaponA, Entity.Null); // no NetworkId on Entity.Null
group.Update();
Assert.AreEqual(0, Slot(em, player, EquipSlotId.Weapon), "An unresolvable sender equips nothing.");
Assert.AreEqual(1, Bag(em, player, WeaponA), "The item stays in the bag.");
Assert.AreEqual(0, RequestsLeft(em), "The request is still consumed.");
}
}
[Test]
public void Strip_Removes_Only_The_Slot_Sentinel_Leaving_Foreign_Mods()
{
var (world, group) = MakeWorld("StripIsolation");
using (world)
{
var em = world.EntityManager;
var conn = MakeConnection(em, 1);
var player = MakePlayer(em, 1, (GearArmor, 1));
// Seed foreign modifiers that unequip must NOT touch: a pickup (SourceId 0) + the ability upgrade.
var mods = em.GetBuffer<StatModifier>(player);
mods.Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 3f, SourceId = 0u });
mods.Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.PercentAdd, Value = 0.25f, SourceId = Tuning.AbilityUpgradeSourceId });
MakeEquip(em, GearArmor, conn);
group.Update();
Assert.AreEqual(1, SlotModCount(em, player, EquipSlotId.Armor), "Gear adds its armor-slot mod.");
MakeUnequip(em, EquipSlotId.Armor, conn);
group.Update();
Assert.AreEqual(0, SlotModCount(em, player, EquipSlotId.Armor), "Unequip strips the armor-slot mod.");
Assert.AreEqual(1, ModCountBySource(em, player, 0u), "The pickup mod (SourceId 0) is untouched.");
Assert.AreEqual(1, ModCountBySource(em, player, Tuning.AbilityUpgradeSourceId), "The upgrade mod is untouched.");
Assert.AreEqual(1, Bag(em, player, GearArmor), "The gear returns to the bag.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 5aa8cc2d95b243d49b7acb4c184df7f2
@@ -1,131 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="InventoryDepositSystem"/> — the RPC that
/// moves a player's PERSONAL inventory into the shared ledger. Mirrors the RPC-receive tests' seeding:
/// a ResourceLedger singleton, a mock connection (NetworkId), a player (GhostOwner + InventorySlot +
/// PlayerTag), and an InventoryDepositRequest + ReceiveRpcCommandRequest. Pins: a specific-item deposit
/// moves the clamped amount; ItemId 0 deposits everything and empties the bag; an unresolvable connection
/// moves nothing; the request is consumed either way.
/// </summary>
public class InventoryDepositSystemTests
{
static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<InventoryDepositSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var ledger = em.CreateEntity(typeof(ResourceLedger));
em.AddBuffer<StorageEntry>(ledger);
return (world, group, ledger);
}
static Entity MakeConnection(EntityManager em, int networkId)
{
var e = em.CreateEntity();
em.AddComponentData(e, new NetworkId { Value = networkId });
return e;
}
static Entity MakePlayer(EntityManager em, int networkId, params (ushort id, int count)[] items)
{
var e = em.CreateEntity();
em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
em.AddComponent<PlayerTag>(e);
var bag = em.AddBuffer<InventorySlot>(e);
foreach (var it in items)
bag.Add(new InventorySlot { ItemId = it.id, Count = it.count });
return e;
}
static void MakeRequest(EntityManager em, ushort itemId, int count, Entity conn)
{
var e = em.CreateEntity();
em.AddComponentData(e, new InventoryDepositRequest { ItemId = itemId, Count = count });
em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn });
}
static int LedgerCount(EntityManager em, Entity ledger, ushort itemId)
{
var buf = em.GetBuffer<StorageEntry>(ledger);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == itemId) return buf[i].Count;
return 0;
}
static int InvCount(EntityManager em, Entity player, ushort itemId)
{
var buf = em.GetBuffer<InventorySlot>(player);
return InventoryMath.CountOf(buf, itemId);
}
[Test]
public void Deposit_Specific_Item_Moves_Clamped_Amount_To_Ledger()
{
var (world, group, ledger) = MakeWorld("DepositSpecific");
using (world)
{
var em = world.EntityManager;
var conn = MakeConnection(em, 1);
var player = MakePlayer(em, 1, (ResourceId.Ore, 30));
MakeRequest(em, ResourceId.Ore, 20, conn);
group.Update();
Assert.AreEqual(10, InvCount(em, player, ResourceId.Ore), "20 of 30 Ore moved out of the bag.");
Assert.AreEqual(20, LedgerCount(em, ledger, ResourceId.Ore), "20 Ore landed in the shared ledger.");
using var q = em.CreateEntityQuery(typeof(InventoryDepositRequest));
Assert.AreEqual(0, q.CalculateEntityCount(), "The request is consumed.");
}
}
[Test]
public void Deposit_All_Empties_Bag_Into_Ledger()
{
var (world, group, ledger) = MakeWorld("DepositAll");
using (world)
{
var em = world.EntityManager;
var conn = MakeConnection(em, 1);
var player = MakePlayer(em, 1, (ResourceId.Ore, 30), (ResourceId.Aether, 5));
MakeRequest(em, itemId: 0, count: 0, conn); // 0 = deposit all
group.Update();
Assert.AreEqual(0, InvCount(em, player, ResourceId.Ore), "Deposit-all empties the bag.");
Assert.AreEqual(0, InvCount(em, player, ResourceId.Aether));
Assert.AreEqual(30, LedgerCount(em, ledger, ResourceId.Ore));
Assert.AreEqual(5, LedgerCount(em, ledger, ResourceId.Aether));
}
}
[Test]
public void Deposit_From_Unresolvable_Connection_Moves_Nothing()
{
var (world, group, ledger) = MakeWorld("DepositUnknown");
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, 1, (ResourceId.Ore, 30));
MakeRequest(em, ResourceId.Ore, 20, Entity.Null); // no NetworkId on Entity.Null
group.Update();
Assert.AreEqual(30, InvCount(em, player, ResourceId.Ore), "An unresolvable sender moves nothing.");
Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Ore));
using var q = em.CreateEntityQuery(typeof(InventoryDepositRequest));
Assert.AreEqual(0, q.CalculateEntityCount(), "The request is still consumed.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e0a222e00ad08444793bf5b1ffccc71a
@@ -1,141 +0,0 @@
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 the harvest -&gt; PERSONAL inventory reroute in
/// <see cref="ResourceHarvestSystem"/>. A bare world is seeded with a ResourceLedger singleton, a node,
/// a player (GhostOwner + InventorySlot + PlayerTag) and an OWNED projectile (matching GhostOwner). Pins:
/// an owned hit lands in the player's inventory and leaves the ledger untouched; a full bag spills the
/// remainder to the ledger; an owned projectile whose NetworkId has no live player falls back to the ledger.
/// The 8 owner-less tests in <see cref="ResourceHarvestSystemTests"/> pin the un-owned -&gt; ledger fallback.
/// </summary>
public class InventoryHarvestTests
{
static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ResourceHarvestSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var ledger = em.CreateEntity(typeof(ResourceLedger));
em.AddBuffer<StorageEntry>(ledger);
return (world, group, ledger);
}
static Entity MakeNode(EntityManager em, float3 pos, float hitRadius, byte resourceId, int remaining, float perHit)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new HitRadius { Value = hitRadius });
em.AddComponentData(e, new ResourceNode { ResourceId = resourceId, Remaining = remaining, HarvestPerHit = perHit });
return e;
}
static Entity MakePlayer(EntityManager em, int networkId)
{
var e = em.CreateEntity();
em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
em.AddComponent<PlayerTag>(e);
em.AddBuffer<InventorySlot>(e);
return e;
}
static Entity MakeOwnedProjectile(EntityManager em, float3 pos, float2 dir, float lastStep, int networkId)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Projectile { Direction = dir, LastStep = lastStep });
em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
return e;
}
static int LedgerCount(EntityManager em, Entity ledger, ushort itemId)
{
var buf = em.GetBuffer<StorageEntry>(ledger);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == itemId) return buf[i].Count;
return 0;
}
static int InvCount(EntityManager em, Entity player, ushort itemId)
{
var buf = em.GetBuffer<InventorySlot>(player);
return InventoryMath.CountOf(buf, itemId);
}
[Test]
public void Owned_Harvest_Lands_In_Player_Inventory_Ledger_Untouched()
{
var (world, group, ledger) = MakeWorld("OwnedHarvest");
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, networkId: 1);
var node = MakeNode(em, new float3(10, 1, 10), 1f, ResourceId.Aether, remaining: 100, perHit: 25f);
var proj = MakeOwnedProjectile(em, new float3(10, 1, 10), new float2(1, 0), 5f, networkId: 1);
group.Update();
Assert.AreEqual(25, InvCount(em, player, ResourceId.Aether), "The owner's harvest lands in their personal inventory.");
Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "The shared ledger is untouched by an owned harvest.");
Assert.AreEqual(75, em.GetComponentData<ResourceNode>(node).Remaining);
Assert.IsFalse(em.Exists(proj), "The projectile is consumed.");
}
}
[Test]
public void Full_Bag_Spills_Remainder_To_Ledger()
{
var (world, group, ledger) = MakeWorld("FullBagSpill");
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, networkId: 1);
// Fill all InventoryMaxSlots with distinct dummy items so no slot is free for the harvested id.
var bag = em.GetBuffer<InventorySlot>(player);
for (int i = 0; i < Tuning.InventoryMaxSlots; i++)
bag.Add(new InventorySlot { ItemId = (ushort)(100 + i), Count = 1 });
var node = MakeNode(em, new float3(10, 1, 10), 1f, ResourceId.Ore, remaining: 100, perHit: 25f);
var proj = MakeOwnedProjectile(em, new float3(10, 1, 10), new float2(1, 0), 5f, networkId: 1);
group.Update();
Assert.AreEqual(0, InvCount(em, player, ResourceId.Ore), "A full bag cannot take the harvested item.");
Assert.AreEqual(25, LedgerCount(em, ledger, ResourceId.Ore), "The full amount spills to the shared ledger.");
Assert.AreEqual(75, em.GetComponentData<ResourceNode>(node).Remaining, "The node decrements by the FULL amount, not just the part that fit.");
Assert.IsFalse(em.Exists(proj));
}
}
[Test]
public void Owned_Projectile_With_No_Matching_Player_Falls_Back_To_Ledger()
{
var (world, group, ledger) = MakeWorld("NoMatchingPlayer");
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, networkId: 1);
var node = MakeNode(em, new float3(10, 1, 10), 1f, ResourceId.Aether, remaining: 100, perHit: 25f);
// Projectile owned by NetworkId 99 — no live player has that id.
var proj = MakeOwnedProjectile(em, new float3(10, 1, 10), new float2(1, 0), 5f, networkId: 99);
group.Update();
Assert.AreEqual(0, InvCount(em, player, ResourceId.Aether), "Player 1 gets nothing — it didn't fire this shot.");
Assert.AreEqual(25, LedgerCount(em, ledger, ResourceId.Aether), "An unresolvable owner falls back to the shared ledger.");
Assert.IsFalse(em.Exists(proj));
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 42fed61a7f84fd740b17ec2c4cff8204
@@ -1,89 +0,0 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Entities;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the pure <see cref="InventoryMath"/> stacking logic (the per-player
/// bag math): top-up-then-append with a per-item stack cap, a max slot count returning a remainder, and
/// back-to-front withdraw clamped to availability. A bare world hosts an entity that owns the
/// <see cref="InventorySlot"/> buffer (a DynamicBuffer needs an entity); no systems run.
/// </summary>
public class InventoryMathTests
{
static (World world, DynamicBuffer<InventorySlot> buffer) MakeBuffer()
{
var world = new World("InventoryMathTest");
var em = world.EntityManager;
var e = em.CreateEntity();
var buffer = em.AddBuffer<InventorySlot>(e);
return (world, buffer);
}
[Test]
public void Deposit_TopsUpExistingStack_ThenAppends_NewStack()
{
var (world, buf) = MakeBuffer();
using (world)
{
int r1 = InventoryMath.Deposit(buf, itemId: 1, count: 5, stackMax: 10, maxSlots: 4);
Assert.AreEqual(0, r1, "5 fits in one fresh stack.");
Assert.AreEqual(1, buf.Length);
int r2 = InventoryMath.Deposit(buf, itemId: 1, count: 8, stackMax: 10, maxSlots: 4);
Assert.AreEqual(0, r2, "8 more tops the first stack to 10 then appends 3.");
Assert.AreEqual(2, buf.Length, "A second stack is appended once the first fills.");
Assert.AreEqual(13, InventoryMath.CountOf(buf, 1));
Assert.AreEqual(10, buf[0].Count, "First stack is capped at stackMax.");
Assert.AreEqual(3, buf[1].Count);
}
}
[Test]
public void Deposit_FillsMultipleStacks_UpToSlotCap_ReturnsRemainder()
{
var (world, buf) = MakeBuffer();
using (world)
{
// 2 slots * stackMax 10 = 20 capacity; depositing 25 leaves a remainder of 5.
int r = InventoryMath.Deposit(buf, itemId: 2, count: 25, stackMax: 10, maxSlots: 2);
Assert.AreEqual(5, r, "Past the slot cap, the overflow is returned as a remainder.");
Assert.AreEqual(2, buf.Length);
Assert.AreEqual(20, InventoryMath.CountOf(buf, 2));
}
}
[Test]
public void Withdraw_TakesAcrossStacks_BackToFront_Clamped_ReturnsTaken()
{
var (world, buf) = MakeBuffer();
using (world)
{
InventoryMath.Deposit(buf, itemId: 3, count: 25, stackMax: 10, maxSlots: 4); // 10,10,5
int taken = InventoryMath.Withdraw(buf, itemId: 3, count: 12);
Assert.AreEqual(12, taken);
Assert.AreEqual(13, InventoryMath.CountOf(buf, 3));
int takenAll = InventoryMath.Withdraw(buf, itemId: 3, count: 100);
Assert.AreEqual(13, takenAll, "Withdraw clamps to what is available.");
Assert.AreEqual(0, InventoryMath.CountOf(buf, 3));
Assert.AreEqual(0, buf.Length, "Emptied stacks are dropped.");
}
}
[Test]
public void Deposit_ZeroItemId_OrNonPositiveCount_AreNoOps()
{
var (world, buf) = MakeBuffer();
using (world)
{
Assert.AreEqual(7, InventoryMath.Deposit(buf, itemId: 0, count: 7, stackMax: 10, maxSlots: 4),
"Depositing the empty id deposits nothing and returns the full count.");
Assert.AreEqual(0, InventoryMath.Deposit(buf, itemId: 1, count: 0, stackMax: 10, maxSlots: 4));
Assert.AreEqual(0, InventoryMath.Deposit(buf, itemId: 1, count: -3, stackMax: 10, maxSlots: 4));
Assert.AreEqual(0, buf.Length, "No rows were written.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: dbdfefb1a0acad849b84fadfb2938d9e
@@ -1,55 +0,0 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Tests
{
/// <summary>
/// Regression guard for the Phase 1 inline-mod design: <see cref="ItemDatabaseBlob.TryGetItem"/> returns the
/// def BY VALUE, so the inline <see cref="ItemModSpec"/> slots must survive that copy. (A nested BlobArray of
/// mods would corrupt its relative-offset pointer on this copy and read empty — the blocker the inline layout
/// avoids.) Looks up the SECOND item by id and reads a non-zero mod value: an index-1 lookup + a non-zero read
/// is exactly what would expose an offset corruption that a length-only check on item 0 could miss.
/// </summary>
public class ItemDatabaseBlobTests
{
[Test]
public void TryGetItem_RoundTrips_Inline_Mods_For_Second_Item()
{
var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<ItemDatabaseBlob>();
var arr = builder.Allocate(ref root.Items, 2);
arr[0] = new ItemDefBlob { ItemId = 100, EquipSlot = EquipSlotId.Weapon };
arr[1] = new ItemDefBlob
{
ItemId = 101,
EquipSlot = EquipSlotId.Armor,
Mod0 = new ItemModSpec { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentAdd, Value = 0.25f },
Mod1 = new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 7f },
Mod2 = new ItemModSpec { Target = 255 },
Mod3 = new ItemModSpec { Target = 255 },
};
var blob = builder.CreateBlobAssetReference<ItemDatabaseBlob>(Allocator.Persistent);
builder.Dispose();
try
{
ref var db = ref blob.Value;
Assert.IsTrue(db.TryGetItem(101, out var def), "Second item resolves by id.");
Assert.AreEqual(EquipSlotId.Armor, def.EquipSlot);
var m0 = def.GetMod(0);
Assert.AreEqual((byte)StatTarget.MoveSpeed, m0.Target, "Inline Mod0 target survives the by-value copy.");
Assert.AreEqual(0.25f, m0.Value, 1e-4f, "Inline Mod0 value survives the by-value copy (would read 0 under a nested-blob corruption).");
var m1 = def.GetMod(1);
Assert.AreEqual((byte)StatTarget.Damage, m1.Target);
Assert.AreEqual(7f, m1.Value, 1e-4f);
Assert.AreEqual(255, def.GetMod(2).Target, "Unused inline slots stay 255.");
}
finally { blob.Dispose(); }
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4933b5824e154374494e80fa6ceaa81c
@@ -1,128 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities tests for <see cref="KillRewardSystem"/> (Phase 1.7 on-kill boons). Siphon heals the credited
/// killer (clamped to their effective max, once per corpse via the Dying.Rewarded latch); Frenzy upserts a single
/// cooldown-reduction row; an unresolved killer (KillerNetId &lt; 0) grants nothing but is still latched.
/// </summary>
public class KillRewardSystemTests
{
const uint T0 = 5000;
static (World world, SimulationSystemGroup group, EntityManager em) MakeWorld()
{
var world = new World("KillRewardTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<KillRewardSystem>());
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) });
return (world, group, em);
}
static Entity MakeKiller(EntityManager em, int netId, byte flags, float hp, float maxHp)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(BoonEffects),
typeof(Health), typeof(EffectiveCharacterStats));
em.AddBuffer<StatModifier>(e);
em.AddBuffer<TimedModifier>(e);
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
em.SetComponentData(e, new BoonEffects { Flags = flags });
em.SetComponentData(e, new Health { Current = hp, Max = maxHp });
em.SetComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHp });
return e;
}
static Entity MakeCorpse(EntityManager em, int killerNetId)
{
var e = em.CreateEntity(typeof(EnemyTag), typeof(Dying));
em.SetComponentData(e, new Dying { UntilTick = T0 + 50, KillerNetId = killerNetId, Rewarded = 0 });
return e;
}
static int FrenzyRows(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.FrenzySourceId) n++;
return n;
}
[Test]
public void Siphon_HealsKiller_ClampedToMax_OncePerCorpse()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Siphon, hp: 50f, maxHp: 130f);
var corpse = MakeCorpse(em, killerNetId: 1);
group.Update();
Assert.Greater(em.GetComponentData<Health>(killer).Current, 50f, "Siphon healed the killer");
Assert.AreEqual(1, em.GetComponentData<Dying>(corpse).Rewarded, "corpse latched as rewarded");
float afterFirst = em.GetComponentData<Health>(killer).Current;
group.Update(); // second tick: Rewarded==1 -> no double-heal
Assert.AreEqual(afterFirst, em.GetComponentData<Health>(killer).Current, 1e-4f, "no double-heal on a re-tick");
}
}
[Test]
public void Siphon_DoesNotOverheal_AboveEffectiveMax()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Siphon, hp: 128f, maxHp: 130f);
MakeCorpse(em, killerNetId: 1);
group.Update();
Assert.AreEqual(130f, em.GetComponentData<Health>(killer).Current, 1e-4f, "heal clamps to the effective max");
}
}
[Test]
public void Frenzy_UpsertsSingleCooldownRow()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Frenzy, hp: 100f, maxHp: 130f);
MakeCorpse(em, killerNetId: 1);
group.Update();
Assert.AreEqual(1, FrenzyRows(em, killer), "one Frenzy StatModifier row");
// A second corpse (new kill) re-stamps rather than stacking.
var c2 = em.CreateEntity(typeof(EnemyTag), typeof(Dying));
em.SetComponentData(c2, new Dying { UntilTick = T0 + 60, KillerNetId = 1, Rewarded = 0 });
group.Update();
Assert.AreEqual(1, FrenzyRows(em, killer), "Frenzy refreshes, never stacks");
}
}
[Test]
public void UnresolvedKiller_GrantsNothing_ButLatches()
{
var (world, group, em) = MakeWorld();
using (world)
{
var killer = MakeKiller(em, 1, BoonFlag.Siphon | BoonFlag.Frenzy, hp: 50f, maxHp: 130f);
var corpse = MakeCorpse(em, killerNetId: -1); // environment/AoE kill — no credit
group.Update();
Assert.AreEqual(50f, em.GetComponentData<Health>(killer).Current, 1e-4f, "no heal for an uncredited kill");
Assert.AreEqual(0, FrenzyRows(em, killer), "no Frenzy for an uncredited kill");
Assert.AreEqual(1, em.GetComponentData<Dying>(corpse).Rewarded, "still latched so it is not reprocessed");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4e10a4fa71c531a42b093a1b43d1ccaf
@@ -484,32 +484,7 @@ namespace ProjectM.Tests
}
}
[Test]
public void Cleave_Harvests_An_Expedition_Node_To_Personal_Inventory_Not_The_Ledger()
{
var (world, group) = MakeWorld("MeleeHarvestExp", 100, server: true);
using (world)
{
var em = world.EntityManager;
var ledger = em.CreateEntity(typeof(ResourceLedger));
em.AddBuffer<StorageEntry>(ledger);
var p = MakePlayer(em, new float2(0, 1)); // GhostOwner NetworkId 7
em.AddComponent<PlayerTag>(p);
em.AddBuffer<InventorySlot>(p);
var node = em.CreateEntity();
em.AddComponentData(node, LocalTransform.FromPosition(new float3(0, 0, 2)));
em.AddComponentData(node, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f });
em.AddComponentData(node, new RegionTag { Region = RegionId.Expedition });
Press(em, p);
group.Update();
var inv = em.GetBuffer<InventorySlot>(p);
Assert.AreEqual(5, InventoryMath.CountOf(inv, ResourceId.Aether), "an expedition-node melee hit lands in the swinging player's PERSONAL inventory.");
Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "an expedition harvest does NOT credit the shared base ledger (DR-026 personal haul).");
Assert.AreEqual(25, em.GetComponentData<ResourceNode>(node).Remaining, "the node is still depleted.");
}
}
@@ -1,136 +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>
/// 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(FrameId), typeof(PlayerTag));
em.SetComponentData(e, LocalTransform.Identity);
em.AddBuffer<StatModifier>(e);
em.AddBuffer<AbilitySocket>(e); // the spawn path SetBuffers the frame loadout unconditionally (LANTERN purge)
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();
using (world)
{
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 FrameKind (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)");
}
}
[Test]
public void MissingCatalog_BlocksSpawn_PreservesRequest()
{
var (world, group) = MakeWorld();
using (world)
{
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");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 923f2724cee01c34f87f8fda67885b11
@@ -1,198 +0,0 @@
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();
using (world)
{
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");
}
}
[Test]
public void TwoSameTick_BarelyEnough_ExactlyOneSucceeds()
{
var (world, group) = MakeWorld();
using (world)
{
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");
}
}
[Test]
public void SecondPurchase_AbsoluteUpsert_SingleRow_RampedCost()
{
var (world, group) = MakeWorld();
using (world)
{
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");
}
}
[Test]
public void Rejects_WrongClassMask_MaxTierCap_NonStaging()
{
var (world, group) = MakeWorld();
using (world)
{
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");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4b835275276dd3149b248a6b6a031ad3
@@ -1,182 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode coverage for the server-only PrepPurchaseSystem (DR-046 base prep-loadout spend).
/// Exercises the afford / soft-fail / once-per-run / non-Staging-reject paths and the DR-014 same-tick atomicity
/// (a second barely-affordable buy in the same tick can't also pass). Modeled on MetaSpendSystemTests.
/// </summary>
public class PrepPurchaseSystemTests
{
// Director carries the RunInfo singleton + the shared ResourceLedger (StorageEntry buffer), seeded with one resource.
static Entity MakeDirector(EntityManager em, byte lifecycle, byte resId, int amount)
{
var e = em.CreateEntity(typeof(RunInfo), typeof(ResourceLedger), typeof(StorageEntry));
em.SetComponentData(e, new RunInfo { Lifecycle = lifecycle });
if (amount > 0)
{
var ledger = em.GetBuffer<StorageEntry>(e);
StorageMath.Deposit(ledger, resId, amount);
}
return e;
}
// A player (PlayerTag + GhostOwner + StatModifier buffer) plus its connection entity (NetworkId).
static (Entity player, Entity conn) MakePlayer(EntityManager em, int netId)
{
var player = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(StatModifier));
em.SetComponentData(player, new GhostOwner { NetworkId = netId });
var conn = em.CreateEntity(typeof(NetworkId));
em.SetComponentData(conn, new NetworkId { Value = netId });
return (player, conn);
}
static void SendRequest(EntityManager em, Entity conn, byte optionId)
{
var e = em.CreateEntity(typeof(PrepPurchaseRequest), typeof(ReceiveRpcCommandRequest));
em.SetComponentData(e, new PrepPurchaseRequest { OptionId = optionId });
em.SetComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn });
}
// Count StatModifier rows in the prep SourceId band on a player.
static int PrepRowCount(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.PrepSourceIdBase && mods[i].SourceId < Tuning.PrepSourceIdBase + 256u)
n++;
return n;
}
static int OpenRequests(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(PrepPurchaseRequest));
return q.CalculateEntityCount();
}
[Test]
public void Purchase_Appends_Prep_Modifier_Withdraws_And_Consumes_Request()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Buy", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 30);
var (player, conn) = MakePlayer(em, 1);
SendRequest(em, conn, 0); // id0: Ore 30 -> MaxHealth +30 Flat
group.Update();
Assert.AreEqual(1, PrepRowCount(em, player), "One prep modifier appended.");
var mods = em.GetBuffer<StatModifier>(player);
Assert.AreEqual((byte)StatTarget.MaxHealth, mods[0].Target);
Assert.AreEqual(30f, mods[0].Value, 1e-4f);
Assert.AreEqual(Tuning.PrepSourceIdBase + 0u, mods[0].SourceId);
Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "Ore fully withdrawn.");
Assert.AreEqual(0, OpenRequests(em), "Request consumed.");
}
}
[Test]
public void Reject_When_Broke_Leaves_Ledger_Untouched_But_Consumes_Request()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Broke", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 20); // < 30 cost
var (player, conn) = MakePlayer(em, 1);
SendRequest(em, conn, 0);
group.Update();
Assert.AreEqual(0, PrepRowCount(em, player), "No modifier when broke.");
Assert.AreEqual(20, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "Pre-check skips Withdraw; ledger untouched.");
Assert.AreEqual(0, OpenRequests(em), "Request still consumed on reject.");
}
}
[Test]
public void Already_Owned_Does_Not_Rebuy()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Owned", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 60);
var (player, conn) = MakePlayer(em, 1);
em.GetBuffer<StatModifier>(player).Add(new StatModifier { SourceId = Tuning.PrepSourceIdBase + 0u, Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f });
SendRequest(em, conn, 0);
group.Update();
Assert.AreEqual(1, PrepRowCount(em, player), "Still exactly one row (once per run == SourceId presence).");
Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "No second withdraw.");
}
}
[Test]
public void Non_Staging_Is_Rejected()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_NonStaging", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.InRoom, ResourceId.Ore, 60);
var (player, conn) = MakePlayer(em, 1);
SendRequest(em, conn, 0);
group.Update();
Assert.AreEqual(0, PrepRowCount(em, player), "No purchase outside Staging.");
Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "Ledger untouched outside Staging.");
Assert.AreEqual(0, OpenRequests(em), "Request consumed even when rejected.");
}
}
[Test]
public void Unknown_Option_Id_Is_Dropped()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Unknown", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 60);
var (player, conn) = MakePlayer(em, 1);
SendRequest(em, conn, 99); // no such row
group.Update();
Assert.AreEqual(0, PrepRowCount(em, player));
Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore));
Assert.AreEqual(0, OpenRequests(em), "Unknown-id request still consumed.");
}
}
[Test]
public void Two_Same_Tick_Barely_Enough_Exactly_One_Succeeds()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Atomic", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Aether, 25); // enough for exactly ONE 25-Aether buy
var (player, conn) = MakePlayer(em, 1);
SendRequest(em, conn, 2); // Aether 25 -> MeleeDamage
SendRequest(em, conn, 3); // Aether 25 -> Damage
group.Update();
Assert.AreEqual(1, PrepRowCount(em, player), "Exactly one of two same-tick 25-Aether buys succeeds (DR-014 in-loop pre-check).");
Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Aether), "Aether withdrawn once; never negative.");
Assert.AreEqual(0, OpenRequests(em), "Both requests consumed.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 02cf1a6cbcc3b804191e5e0169c1402d
@@ -1,205 +0,0 @@
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();
using (world)
{
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");
}
}
[Test]
public void Toggle_Ignored_MidRun()
{
var (world, group, dir) = MakeWorld();
using (world)
{
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");
}
}
[Test]
public void PartialReady_DoesNotLaunch()
{
var (world, group, dir) = MakeWorld();
using (world)
{
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");
}
}
[Test]
public void UnReady_DuringCountdown_Aborts()
{
var (world, group, dir) = MakeWorld();
using (world)
{
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");
}
}
[Test]
public void Launch_TeleportsPartyOut_ThenHome_AndClearsReady()
{
var (world, group, dir) = MakeWorld();
using (world)
{
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);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 046f63edcabb72548843c84fcd99dda1
@@ -1,198 +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 <see cref="RoomEnemyDirectorSystem"/> (the Step-6 successor of the retired
/// ZoneEnemyDirectorSystem). Pins: the per-RoomEpoch reseed sized by ZoneEnemyMath on the room's DifficultyEpoch;
/// spawns at the ACTIVE sub-slot origin carrying the full tag stack (RegionTag{Expedition} + ZoneEnemyTag +
/// RoomTag) with baked Scale preserved; the Boss room's single scaled boss; the MaxAlive pack-fit wait; and the
/// ExpeditionObjective Cleared/Idle latch written above the early-returns.
/// </summary>
public class RoomEnemyDirectorSystemTests
{
const uint Seed = 777u;
const uint T0 = 500;
static (World world, SimulationSystemGroup group, Entity runDir, Entity zoneDir) MakeWorld(
int currentRoom, int currentNodeId, byte activeSubSlot, int maxAlive = 10)
{
var world = new World("RoomEnemyTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<RoomEnemyDirectorSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var nt = em.CreateEntity(typeof(NetworkTime));
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) });
var map = RunMapMath.Generate(Seed);
var runDir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective));
em.SetComponentData(runDir, new RunInfo
{
Lifecycle = RunLifecycle.InRoom,
CurrentRoom = currentRoom,
RoomCount = map.LayerCount,
});
em.SetComponentData(runDir, new RunRuntime
{
RunSeed = Seed,
RoomEpoch = 1,
CurrentNodeId = currentNodeId,
ActiveSubSlot = activeSubSlot,
});
var grunt = MakeEnemyPrefab(em);
var charger = MakeEnemyPrefab(em);
var zoneDir = em.CreateEntity(typeof(ZoneEnemyDirector), typeof(ZoneEnemyState));
em.SetComponentData(zoneDir, new ZoneEnemyDirector
{
MaxAlive = maxAlive, RingRadius = 14f, RingSlots = 10, SpawnIntervalTicks = 10,
GruntsPerWave = 4, ChargersPerWave = 1, SwarmerPackSize = 3, ClusterTightRadius = 1.5f, RewardOre = 25,
});
var buf = em.AddBuffer<ZoneEnemyPrefab>(zoneDir);
buf.Add(new ZoneEnemyPrefab { Prefab = grunt });
buf.Add(new ZoneEnemyPrefab { Prefab = charger });
return (world, group, runDir, zoneDir);
}
static Entity MakeEnemyPrefab(EntityManager em)
{
var e = em.CreateEntity(typeof(LocalTransform), typeof(EnemyTag), typeof(Health));
em.SetComponentData(e, LocalTransform.Identity); // Scale = 1 so WithPosition keeps it
em.SetComponentData(e, new Health { Current = 50f, Max = 50f });
em.AddComponent<Prefab>(e);
return e;
}
static MixBands Bands() => new MixBands { GruntBase = 4, ChargerBase = 1 };
static int Alive(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag));
return q.CalculateEntityCount();
}
static void AdvanceTick(EntityManager em, uint tick)
{
using var q = em.CreateEntityQuery(typeof(NetworkTime));
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
}
[Test]
public void Seeds_ByRoomDifficulty_AndSpawnsTaggedAtActiveSlotOrigin()
{
var (world, group, runDir, zoneDir) = MakeWorld(currentRoom: 0, currentNodeId: RunMap.NodeId(0, 0), activeSubSlot: 0);
using (world)
{
var em = world.EntityManager;
var map = RunMapMath.Generate(Seed);
var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount);
int slots = ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, Bands());
group.Update(); // seeds; the landing grace holds the first slot (demo polish)
Assert.AreEqual(slots, em.GetComponentData<ZoneEnemyState>(zoneDir).RemainingToSpawn,
"grace: nothing spawns on the seed tick");
AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1);
group.Update(); // grace elapsed -> the first slot drips
var zs = em.GetComponentData<ZoneEnemyState>(zoneDir);
Assert.AreEqual(1, zs.SeededEpoch, "seeded for RoomEpoch 1");
Assert.AreEqual(slots - 1, zs.RemainingToSpawn, "wave sized by ZoneEnemyMath on the room's DifficultyEpoch");
Assert.AreEqual(1, Alive(em), "first slot drip-spawned");
var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(RoomTag), typeof(RegionTag), typeof(LocalTransform));
Assert.AreEqual(1, q.CalculateEntityCount(), "spawn carries the FULL tag stack (Zone + Room + Region)");
var xfs = q.ToComponentDataArray<LocalTransform>(Allocator.Temp);
var regs = q.ToComponentDataArray<RegionTag>(Allocator.Temp);
var rooms = q.ToComponentDataArray<RoomTag>(Allocator.Temp);
Assert.AreEqual(RegionId.Expedition, regs[0].Region);
Assert.AreEqual(0, rooms[0].Room);
Assert.AreEqual(1f, xfs[0].Scale, 1e-4f, "baked Scale preserved");
float3 origin = RegionMath.ExpeditionRoomOrigin(new float3(0f, 1f, 0f), 0);
Assert.LessOrEqual(math.distance(xfs[0].Position.xz, origin.xz), 14f + 0.01f,
"ring-spawned around the ACTIVE sub-slot origin");
xfs.Dispose(); regs.Dispose(); rooms.Dispose(); q.Dispose(); // dispose BEFORE the worldispose();
}
}
[Test]
public void BossRoom_SpawnsSingleScaledBoss()
{
var map = RunMapMath.Generate(Seed);
int bossLayer = map.LayerCount - 1;
var (world, group, runDir, zoneDir) = MakeWorld(bossLayer, map.BossNodeId, activeSubSlot: (byte)(bossLayer & 1));
using (world)
{
var em = world.EntityManager;
group.Update(); // seed tick (the landing grace holds the boss)
AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1);
group.Update(); // grace elapsed -> the boss spawns
var zs = em.GetComponentData<ZoneEnemyState>(zoneDir);
Assert.AreEqual(0, zs.RemainingToSpawn, "a boss room is a single-slot wave, fully spawned");
Assert.AreEqual(1, Alive(em), "exactly one boss");
var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(Health), typeof(LocalTransform));
var hps = q.ToComponentDataArray<Health>(Allocator.Temp);
var xfs = q.ToComponentDataArray<LocalTransform>(Allocator.Temp);
Assert.AreEqual(50f * Tuning.BossHealthMultiplier, hps[0].Max, 1e-3f, "boss health scaled");
Assert.AreEqual(Tuning.BossScaleMultiplier, xfs[0].Scale, 1e-3f, "boss visual scale bumped");
hps.Dispose(); xfs.Dispose(); q.Dispose(); // dispose BEFORE the world (a using-var here outlives it);
}
}
[Test]
public void Objective_ClearedLatch_AndIdleOutsideRooms()
{
var (world, group, runDir, zoneDir) = MakeWorld(0, RunMap.NodeId(0, 0), 0);
using (world)
{
var em = world.EntityManager;
// Fabricate a fully-spawned, fully-dead wave for the CURRENT room epoch.
em.SetComponentData(zoneDir, new ZoneEnemyState { SeededEpoch = 1, RemainingToSpawn = 0, SpawnCounter = 5 });
group.Update();
Assert.AreEqual(ExpeditionObjectiveState.Cleared, em.GetComponentData<ExpeditionObjective>(runDir).State,
"fully-spawned + zero-alive latches Cleared for the seeded epoch");
var info = em.GetComponentData<RunInfo>(runDir);
info.Lifecycle = RunLifecycle.Staging;
em.SetComponentData(runDir, info);
group.Update();
Assert.AreEqual(ExpeditionObjectiveState.Idle, em.GetComponentData<ExpeditionObjective>(runDir).State,
"no active room -> Idle (objective still written above the early-return)");
}
}
[Test]
public void MaxAlive_PackFitWaits_WithoutConsumingTheSlot()
{
var (world, group, runDir, zoneDir) = MakeWorld(0, RunMap.NodeId(0, 0), 0, maxAlive: 1);
using (world)
{
var em = world.EntityManager;
// One zone enemy already alive fills the cap.
var blocker = em.CreateEntity(typeof(ZoneEnemyTag));
// Pre-seed so the tick goes straight to the drip branch.
em.SetComponentData(zoneDir, new ZoneEnemyState { SeededEpoch = 1, RemainingToSpawn = 2, NextSpawnTick = 0 });
group.Update();
var zs = em.GetComponentData<ZoneEnemyState>(zoneDir);
Assert.AreEqual(1, Alive(em), "cap full -> nothing spawned");
Assert.AreEqual(2, zs.RemainingToSpawn, "the slot WAITS (not consumed) until the pack fits");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 0a5bf96c5c42e5240a8db55fc3e2a01a
@@ -1,159 +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.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);
using (world)
{
var em = world.EntityManager;
var map = RunMapMath.Generate(Seed);
var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount);
int expected = math.min(plan.NodeCount, 12);
float3 origin = RegionMath.ExpeditionRoomOrigin(new float3(0f, 1f, 0f), 0);
group.Update();
Assert.AreEqual(expected, LiveNodes(em), "one room's plan-count scatter");
using (var q = em.CreateEntityQuery(typeof(ResourceNode), typeof(RoomTag), typeof(LocalTransform)))
{
var tags = q.ToComponentDataArray<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");
}
}
[Test]
public void Budget_FloorsSpawnCount_AndExhausts()
{
var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 1);
using (world)
{
var em = world.EntityManager;
group.Update();
Assert.AreEqual(1, LiveNodes(em), "budget of 1 floors the room to a single node");
Assert.AreEqual(0, em.GetComponentData<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)");
}
}
[Test]
public void StagingSweep_KillsRoomGhosts_SparesUntagged()
{
var (world, group, dir, spawnerE, prefab) = MakeWorld(nodeBudget: 12);
using (world)
{
var em = world.EntityManager;
group.Update();
Assert.Greater(LiveNodes(em), 0, "room content exists");
// An untagged node (e.g. the base mining field) must be structurally untouchable.
var baseNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode));
em.SetComponentData(baseNode, LocalTransform.FromPosition(new float3(20f, 0f, 0f)));
var info = em.GetComponentData<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)");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 9b72710f2e3958f4a827cb3befcc8850
@@ -1,93 +0,0 @@
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);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 12aadace80f53cd46a8b2699d232f888
@@ -1,71 +0,0 @@
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");
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 95327306dceec754aa635447fc9d04d4
@@ -1,182 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
using System.Collections.Generic;
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;
readonly List<World> _worlds = new();
[TearDown]
public void Cleanup()
{
foreach (var w in _worlds) if (w.IsCreated) w.Dispose();
_worlds.Clear();
}
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();
using (world)
{
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");
}
}
[Test]
public void TwoSameTickPicks_FirstWins()
{
var (world, group, dir) = MakeGateWorld();
using (world)
{
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");
}
}
[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();
_worlds.Add(w1);
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));
// Wrong layer (a pick from the previous gate of the SAME run).
var (w2, g2, d2) = MakeGateWorld();
_worlds.Add(w2);
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");
// Option index out of the published range.
var (w3, g3, d3) = MakeGateWorld();
_worlds.Add(w3);
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");
// Base-region sender (N3): a home-bound joiner cannot commit the party's route.
var (w4, g4, d4) = MakeGateWorld();
_worlds.Add(w4);
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)");
// Gate closed (mid-room): the pick is dropped, never queued.
var (w5, g5, d5) = MakeGateWorld(lifecycle: RunLifecycle.InRoom);
_worlds.Add(w5);
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");
}
[Test]
public void AlreadyLatched_LaterPickIgnored()
{
var (world, group, dir) = MakeGateWorld();
using (world)
{
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");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c6b6345371289f64188653851f25d8d1
@@ -1,271 +0,0 @@
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(PortalCommand), typeof(MetaCounters), 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
});
// Mid-run fixture: the launch edge would have stamped the roster tag (RunParticipant) — fabricate it.
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
typeof(LocalTransform), typeof(RunParticipant));
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 });
// DR-046: drive the RoomExplore loot window past its portal gate (interact the portal, then tick).
static void PortalAdvance(EntityManager em, SimulationSystemGroup group, Entity dir)
{
em.SetComponentData(dir, new PortalCommand { HasInteract = 1 });
group.Update();
}
static int RoomEntities(EntityManager em)
{
var q = em.CreateEntityQuery(typeof(RoomTag));
int n = q.CalculateEntityCount();
q.Dispose();
return n;
}
[Test]
public void Cleared_LootWindowThenPortalAdvances_WithSlotFlipAndEpochBump()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
using (world)
{
var em = world.EntityManager;
var node0 = em.CreateEntity(typeof(RoomTag));
em.SetComponentData(node0, new RoomTag { Room = 0 });
MarkCleared(em, dir);
group.Update(); // InRoom -> RoomReward (DR-046: room PERSISTS now, no teardown here)
Assert.AreEqual(RunLifecycle.RoomReward, em.GetComponentData<RunInfo>(dir).Lifecycle);
Assert.AreEqual(1, RoomEntities(em), "DR-046: the cleared room persists into the loot window");
Assert.AreEqual(1, em.GetComponentData<RunRuntime>(dir).RoomsClearedThisRun, "honest depth counter");
group.Update(); // RoomReward -> RoomExplore (loot window; portal up)
Assert.AreEqual(RunLifecycle.RoomExplore, em.GetComponentData<RunInfo>(dir).Lifecycle);
Assert.AreEqual(1, RoomEntities(em), "nodes still lootable during RoomExplore");
PortalAdvance(em, group, dir); // interact the portal -> teardown + open the route gate
var gateInfo = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "portal advances to the branching gate");
Assert.AreEqual(0, RoomEntities(em), "room torn down AT the portal exit (the empty-tick guarantee)");
Assert.Greater((int)gateInfo.RouteOptionCount, 0, "authoritative options published");
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 -> 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");
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 (+1500)");
}
}
[Test]
public void BossClear_Returns_AndBanksExactlyOnce_ClearGated()
{
RunMap map0;
var (world, group, dir, player) = MakeMidRunWorld(0, out map0);
using (world)
{
var em = world.EntityManager;
int bossLayer = map0.LayerCount - 1;
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; room persists)
group.Update(); // RoomReward -> RoomExplore
PortalAdvance(em, group, dir); // portal -> Returning (boss cleared)
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");
var meta = em.GetComponentData<MetaCounters>(dir);
Assert.AreEqual(1, meta.RunsCompleted, "run completed");
Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared");
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "save checkpoint requested");
Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated");
group.Update();
group.Update();
Assert.AreEqual(1, em.GetComponentData<MetaCounters>(dir).RunsCompleted);
}
}
[Test]
public void Abort_BanksDepthOnly_NoWinCredit()
{
var (world, group, dir, player) = MakeMidRunWorld(2, out var map);
using (world)
{
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);
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");
Assert.AreEqual(0, em.GetComponentData<SaveRequest>(dir).Pending, "no save spam on abort");
}
}
[Test]
public void RouteGate_PickBeatsSameTickGrace()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
using (world)
{
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update();
group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> RouteSelect (route grace armed)
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;
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
SetTick(world, T0 + 100000);
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");
}
}
[Test]
public void RouteGate_GraceAutoPicksLowestOption()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
using (world)
{
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update();
group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> RouteSelect
var gate = em.GetComponentData<RunInfo>(dir);
byte lowestCol = gate.RouteOpt0Col;
SetTick(world, T0 + 100000);
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");
}
}
[Test]
public void RouteGate_Abort_ClosesGateOnTheEdge()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
using (world)
{
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update();
group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> 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)");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 0b3e546718a6f9846a320e56d5b1acb4
@@ -1,197 +0,0 @@
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));
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ed13d85c05c6fc8469ae19c07647e32e
@@ -1,140 +0,0 @@
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>
/// Regression pins for the two post-impl-review fixes on <see cref="RunDirectorSystem"/>:
/// (1) a dead-respawned (base-region) player's stale <see cref="BoonOffer.Pending"/> neither holds the
/// RoomReward exit gate nor survives it (the wedge that stalled every reward 30 s and left the modal
/// pickable through the next fight); (2) room advances teleport ONLY <see cref="RunParticipant"/>s —
/// a dead-respawned participant is re-conscripted (operator-locked default) while a mid-run late joiner
/// stays at base (spec §2.2 closed party).
/// </summary>
public class RunRosterRegressionTests
{
const uint Seed = 777u;
const uint T0 = 2000;
static (World world, SimulationSystemGroup group, Entity dir) MakeWorld(out RunMap map)
{
var world = new World("RosterTest");
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(SaveRequest));
return (world, group, dir);
}
static Entity MakePlayer(EntityManager em, byte region, bool participant, byte pending)
{
var player = participant
? em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
typeof(LocalTransform), typeof(BoonOffer), typeof(RunParticipant))
: em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
typeof(LocalTransform), typeof(BoonOffer));
em.SetComponentData(player, new RegionTag { Region = region });
em.SetComponentData(player, LocalTransform.Identity);
em.SetComponentData(player, new BoonOffer { Pending = pending, Option0 = 1, Option1 = 2, Option2 = 3 });
return player;
}
[Test]
public void StalePendingAtBase_DoesNotHoldGate_AndIsStrippedOnExit()
{
var (world, group, dir) = MakeWorld(out var map);
using (world)
{
var em = world.EntityManager;
em.SetComponentData(dir, new RunInfo
{
Lifecycle = RunLifecycle.RoomReward,
CurrentRoom = 1,
CurrentCol = 0,
RoomCount = map.LayerCount,
RunSeed = Seed,
});
em.SetComponentData(dir, new RunRuntime
{
RunSeed = Seed,
RunEpoch = 1,
RoomEpoch = 2,
// Grace far in the future: ONLY the all-picked path can advance this tick.
RewardGraceTick = TickUtil.NonZero(T0 + 100000),
});
var alive = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // picked already
var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 1); // the wedge
group.Update();
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreNotEqual(RunLifecycle.RoomReward, info.Lifecycle,
"a base-region player's stale Pending must not hold the reward gate");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(deadAtBase).Pending,
"the stale offer is stripped on the gate exit");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(alive).Pending,
"no offer survives the gate");
}
}
[Test]
public void Advance_TeleportsOnlyParticipants_LateJoinerStaysAtBase()
{
var (world, group, dir) = MakeWorld(out var map);
using (world)
{
var em = world.EntityManager;
em.SetComponentData(dir, new RunInfo
{
Lifecycle = RunLifecycle.RouteSelect,
CurrentRoom = 0,
CurrentCol = 0,
RoomCount = map.LayerCount,
RunSeed = Seed,
RouteOptionCount = 1,
RouteOpt0Col = 0,
});
em.SetComponentData(dir, new RunRuntime
{
RunSeed = Seed,
RunEpoch = 1,
RoomEpoch = 1,
RouteGraceTick = TickUtil.NonZero(T0 + 100000),
});
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = 0, ForRunEpoch = 1, ForLayer = 0 });
var inRoom = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // fighting on
var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 0); // re-conscripted
var lateJoiner = MakePlayer(em, RegionId.Base, participant: false, pending: 0); // stays home
group.Update();
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle, "the committed pick advances the run");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(inRoom).Position.x, 1000f,
"the in-room participant rides the advance");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(deadAtBase).Position.x, 1000f,
"a dead-respawned participant is re-conscripted onto the new room");
Assert.AreEqual(0f, em.GetComponentData<LocalTransform>(lateJoiner).Position.x,
"a non-participant late joiner is never yanked into the fight");
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(lateJoiner).Region,
"the late joiner stays in the Base relevancy bucket");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 31b988b0527c7c247ad1976aab05d470
@@ -1,205 +0,0 @@
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>
/// MC-2 system tests for the EnemyAISystem SPITTER pass (server-only, plain SimulationSystemGroup). Covers the
/// headline ranged mechanic end-to-end: an in-band, ready Spitter commits a telegraphed wind-up then on elapse
/// spawns a spit carrying the FIRING Spitter's Region (fired from EXPEDITION so a dropped Region copy — which would
/// leave the prefab default 0 = Base — fails the assertion), aimed at the target. The HOLD-RANGE gate (DR-041) is
/// pinned by negative tests: a Spitter ADVANCING from out of band does NOT telegraph; a cornered Spitter fires
/// point-blank. The discriminator partition (no double-move) is asserted DIRECTLY (the wind-up value alone can't
/// prove it — the Spitter pass runs last and overwrites it). Soft-fail over the concurrent cap = short retry, no
/// full-cooldown burn. Plain-Entities world, faked NetworkTime + a SpitterProjectilePrefab singleton; the prefab
/// entity is Prefab-tagged so it is excluded from the live-spit count and cloned (minus the tag) on Instantiate.
/// </summary>
public class SpitterBrainTests
{
static void SetTick(World w, uint tick)
{
var em = w.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World, SimulationSystemGroup) AiWorld(uint tick)
{
var w = new World("SpitterBrain");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyAISystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetTick(w, tick);
return (w, g);
}
static Entity MakeSpitPrefab(EntityManager em, float range = 16f)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(float3.zero));
em.AddComponentData(e, new EnemyProjectile { Direction = new float2(0, 1), Speed = 11f, Damage = 0f, Range = range, Region = 0 });
em.AddComponent<Prefab>(e); // excluded from the live-spit query; stripped on Instantiate
return e;
}
static void SetSpitSingleton(EntityManager em, Entity prefab, int maxLive)
{
var s = em.CreateEntity(typeof(SpitterProjectilePrefab));
em.SetComponentData(s, new SpitterProjectilePrefab { Prefab = prefab, MaxLiveProjectiles = maxLive });
}
static Entity MakeSpitter(EntityManager em, float3 pos, byte region, int windupTicks = 1, int cooldown = 60)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponent<EnemyTag>(e);
em.AddComponentData(e, new EnemyStats { MoveSpeed = 4f, AttackRange = 1.5f, AttackDamage = 8f, AttackCooldownTicks = cooldown });
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0u });
em.AddComponentData(e, new KnockbackState { Dir = default, Speed = 0f, UntilTick = 0u });
em.AddComponentData(e, new AttackWindup { WindUpUntilTick = 0u });
em.AddComponentData(e, new SpitterState { PreferredRange = 9f, RangeTolerance = 1.5f, ProjectileSpeed = 11f, CorneredRange = 3f, WindupTicks = windupTicks, NextShotTick = 0u });
em.AddComponentData(e, new RegionTag { Region = region });
return e;
}
static void MakePlayer(EntityManager em, float3 pos, byte region)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponentData(e, new RegionTag { Region = region });
em.AddComponent<PlayerTag>(e);
}
static int CountSpits(EntityManager em)
{
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
return q.CalculateEntityCount();
}
[Test]
public void Spitter_InBand_CommitsThenFires_SpitCarriesFiringRegion()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
var prefab = MakeSpitPrefab(em, range: 16f);
SetSpitSingleton(em, prefab, maxLive: 24);
// Fire from EXPEDITION (!=0): a dropped Region copy would leave 0 (Base) and fail the region assert.
MakePlayer(em, new float3(0, 1, 0), RegionId.Expedition);
var spitter = MakeSpitter(em, new float3(9, 1, 0), RegionId.Expedition, windupTicks: 1); // distance == PreferredRange -> in-band
g.Update(); // tick 200: in-band + ready -> commit the telegraph wind-up to 201
Assert.AreEqual(TickUtil.NonZero(201u), em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick,
"an in-band, ready Spitter commits a wind-up of SpitterState.WindupTicks (the partition itself is asserted in Spitter_IsExcludedFromGruntAndChargerPasses)");
Assert.AreEqual(0, CountSpits(em), "no spit yet — still telegraphing the dodge window");
SetTick(w, 202); // the wind-up tick (201) has now elapsed
g.Update();
Assert.AreEqual(1, CountSpits(em), "the spit fires when the wind-up elapses in-band");
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
var spit = q.GetSingleton<EnemyProjectile>();
Assert.AreEqual(RegionId.Expedition, spit.Region, "the spit carries the FIRING Spitter's region (Expedition!=0 -> a dropped copy fails this)");
Assert.Less(spit.Direction.x, 0f, "aimed back toward the player at the origin");
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick, "the wind-up is cleared after firing");
}
}
[Test]
public void Spitter_OutOfBand_DoesNotCommitWindup()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
MakePlayer(em, new float3(0, 1, 0), RegionId.Base);
var spitter = MakeSpitter(em, new float3(40, 1, 0), RegionId.Base, windupTicks: 1); // dist 40 >> PreferredRange+tol -> advancing
g.Update();
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick,
"a Spitter ADVANCING from out of band must NOT telegraph/fire (the hold-range gate, DR-041)");
}
}
[Test]
public void Spitter_Cornered_CommitsWindupPointBlank()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
MakePlayer(em, new float3(0, 1, 0), RegionId.Base);
var spitter = MakeSpitter(em, new float3(2, 1, 0), RegionId.Base, windupTicks: 5); // dist 2 < CorneredRange 3 -> point-blank
g.Update();
Assert.AreNotEqual(0u, em.GetComponentData<AttackWindup>(spitter).WindUpUntilTick,
"a cornered Spitter (target inside CorneredRange) fires point-blank rather than holding fire");
}
}
[Test]
public void Spitter_IsExcludedFromGruntAndChargerPasses()
{
var w = new World("SpitterRouting");
using (w)
{
var em = w.EntityManager;
MakeSpitter(em, new float3(9, 1, 0), RegionId.Base);
// The three EnemyAISystem pass partitions, asserted directly so a regression in any WithNone guard is caught.
using var gruntQ = em.CreateEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadOnly<EnemyTag>() },
None = new[] { ComponentType.ReadOnly<LungeState>(), ComponentType.ReadOnly<SpitterState>() },
});
using var chargerQ = em.CreateEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadOnly<EnemyTag>() },
None = new[] { ComponentType.ReadOnly<SpitterState>() },
});
using var spitterQ = em.CreateEntityQuery(new EntityQueryDesc
{
All = new[] { ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<SpitterState>() },
None = new[] { ComponentType.ReadOnly<LungeState>() },
});
Assert.AreEqual(0, gruntQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Grunt pass (WithNone<LungeState,SpitterState>)");
Assert.AreEqual(0, chargerQ.CalculateEntityCount(), "a Spitter must NOT be visited by the Charger pass (WithNone<SpitterState>)");
Assert.AreEqual(1, spitterQ.CalculateEntityCount(), "a Spitter IS visited by exactly the Spitter pass");
}
}
[Test]
public void Spitter_OverSoftCap_SkipsFire_ShortRetryNoCooldownBurn()
{
var (w, g) = AiWorld(200);
using (w)
{
var em = w.EntityManager;
var prefab = MakeSpitPrefab(em);
SetSpitSingleton(em, prefab, maxLive: 2);
// pre-fill the live-spit pool to the cap (no Prefab tag -> counted by the soft-cap query)
for (int i = 0; i < 2; i++)
{
var s = em.CreateEntity();
em.AddComponentData(s, LocalTransform.FromPosition(new float3(i, 1, 0)));
em.AddComponentData(s, new EnemyProjectile { Direction = new float2(0, 1), Speed = 11f, Range = 16f, Region = RegionId.Base });
}
MakePlayer(em, new float3(0, 1, 0), RegionId.Base);
var spitter = MakeSpitter(em, new float3(9, 1, 0), RegionId.Base, windupTicks: 1, cooldown: 60);
g.Update(); // tick 200: in-band -> commit the wind-up to 201
SetTick(w, 202); // elapsed
g.Update(); // at the cap -> soft-fail
Assert.AreEqual(2, CountSpits(em), "at the concurrent cap the Spitter does NOT spawn another spit");
Assert.AreEqual(TickUtil.NonZero(210u), em.GetComponentData<SpitterState>(spitter).NextShotTick,
"soft-fail schedules a short retry (now+8 = 210), NOT a full cooldown (now+60 = 262)");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 2fc1d0d0241a80745a308575f71c701b
@@ -1,107 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="StorageOpReceiveSystem"/> — the RPC handler
/// that applies deposit/withdraw ops to the shared storage container's replicated <c>StorageEntry</c> buffer.
/// A bare world with a <c>SharedStorageContainer</c> singleton (carrying the buffer) plus synthetic
/// <c>StorageOpRequest</c> + <c>ReceiveRpcCommandRequest</c> entities exercises the handler. The system plays
/// its ECB back immediately (Temp allocator), so the handled request entity is destroyed within the single
/// group update. Mirrors HealthApplyDamageSystemTests. Locks the deposit/withdraw/drop-row behaviour before
/// the Stage-C const refactor and any later storage-model changes.
/// </summary>
public class StorageOpReceiveSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<StorageOpReceiveSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
return (world, group);
}
static Entity MakeContainer(EntityManager em, ushort itemId, int count)
{
var e = em.CreateEntity(typeof(SharedStorageContainer));
var buf = em.AddBuffer<StorageEntry>(e);
if (itemId != 0)
buf.Add(new StorageEntry { ItemId = itemId, Count = count });
return e;
}
static void MakeRequest(EntityManager em, byte op, ushort itemId, int count)
{
var e = em.CreateEntity();
em.AddComponentData(e, new StorageOpRequest { Op = op, ItemId = itemId, Count = count });
em.AddComponentData(e, default(ReceiveRpcCommandRequest));
}
[Test]
public void Withdraw_Decrements_Existing_Row_And_Destroys_Request()
{
var (world, group) = MakeWorld("StorageWithdrawWorld");
using (world)
{
var em = world.EntityManager;
var container = MakeContainer(em, itemId: 1, count: 100);
MakeRequest(em, StorageOp.Withdraw, itemId: 1, count: 30);
group.Update();
var buf = em.GetBuffer<StorageEntry>(container);
Assert.AreEqual(1, buf.Length, "A partial withdraw keeps the row.");
Assert.AreEqual(70, buf[0].Count, "100 - 30 = 70 must remain.");
using var reqQuery = em.CreateEntityQuery(typeof(StorageOpRequest));
Assert.AreEqual(0, reqQuery.CalculateEntityCount(),
"The handled request entity must be destroyed by the system's ECB.");
}
}
[Test]
public void Deposit_Of_New_Item_Appends_A_Row()
{
var (world, group) = MakeWorld("StorageDepositWorld");
using (world)
{
var em = world.EntityManager;
var container = MakeContainer(em, itemId: 1, count: 100);
MakeRequest(em, StorageOp.Deposit, itemId: 2, count: 20);
group.Update();
var buf = em.GetBuffer<StorageEntry>(container);
Assert.AreEqual(2, buf.Length, "Depositing a previously-absent item appends a second row.");
int item2 = -1;
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == 2) item2 = buf[i].Count;
Assert.AreEqual(20, item2, "The appended row carries the deposited count.");
}
}
[Test]
public void Withdraw_Of_Full_Stack_Drops_The_Row()
{
var (world, group) = MakeWorld("StorageWithdrawZeroWorld");
using (world)
{
var em = world.EntityManager;
var container = MakeContainer(em, itemId: 1, count: 30);
MakeRequest(em, StorageOp.Withdraw, itemId: 1, count: 30);
group.Update();
var buf = em.GetBuffer<StorageEntry>(container);
Assert.AreEqual(0, buf.Length, "Withdrawing the whole stack drops the row entirely.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 62eb6ac6dd96837468c04d1d89b39499
@@ -1,114 +0,0 @@
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>
/// MC-2 system tests for the SWARMER cluster spawn in the base-siege WaveSystem (fork 4a). A swarmer composition
/// slot must instantiate a whole PACK in one tick (EnemyAIMath.ClusterOffset) while consuming exactly ONE wave
/// SLOT, and MaxAlive must count ENTITIES — a pack that won't fit is DEFERRED (slot kept) rather than partially
/// spawned (the review-flagged slot-vs-entity accounting). Plain-Entities world, server WaveSystem registered
/// directly, faked NetworkTime; the 4-entry [Grunt,Charger,Spitter,Swarmer] roster is Prefab-tagged so the
/// instances (and only the instances) count as live EnemyTag ghosts.
/// </summary>
public class SwarmerClusterSpawnTests
{
static void SetTick(World w, uint tick)
{
var em = w.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World, SimulationSystemGroup) WaveWorld(uint tick)
{
var w = new World("SwarmerCluster");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<WaveSystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetTick(w, tick);
return (w, g);
}
// Director with a swarmer-only band so slot 0 is unambiguously a swarmer pack. The 4-entry roster aliases
// dummy Prefab-tagged EnemyTag prefabs; WaveSystem reads index [3] (KindSwarmer) and instantiates the pack.
static Entity MakeDirector(EntityManager em, int swarmerSlotBase, int packSize, int maxAlive)
{
// Create the 4 prefab entities FIRST: every em.CreateEntity is a structural change, so a DynamicBuffer
// handle grabbed before them would be invalidated (the bug this ordering avoids). The roster aliases
// dummy Prefab-tagged EnemyTag prefabs; WaveSystem reads index [3] (KindSwarmer) and instantiates the pack.
var prefabs = new Entity[4];
for (int i = 0; i < 4; i++)
{
var p = em.CreateEntity();
em.AddComponentData(p, LocalTransform.FromPosition(float3.zero));
em.AddComponent<EnemyTag>(p);
em.AddComponent<Prefab>(p);
prefabs[i] = p;
}
var dir = em.CreateEntity();
em.AddComponentData(dir, new WaveDirector
{
RingRadius = 10f, RingSlots = 8, BaseCount = 0, CountPerWave = 0,
SpawnIntervalTicks = 1, LullTicks = 1, MaxAlive = maxAlive,
ChargerBase = 0, SpitterBase = 0, SwarmerSlotBase = swarmerSlotBase,
ChargerPerEpoch = 0, SpitterPerEpoch = 0, SwarmerSlotPerEpoch = 0,
SwarmerPackSize = packSize, SwarmerPackPerEpoch = 0, ClusterTightRadius = 2.5f,
});
em.AddComponentData(dir, new WaveState { WaveNumber = 0, Phase = WavePhase.Lull, NextActionTick = 0u, RemainingToSpawn = 0, SpawnCounter = 0 });
// AddBuffer LAST (after every structural change on dir), then populate with no further structural change.
var buf = em.AddBuffer<WaveEnemyPrefab>(dir);
for (int i = 0; i < 4; i++) buf.Add(new WaveEnemyPrefab { Prefab = prefabs[i] });
return dir;
}
static int CountEnemies(EntityManager em)
{
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<EnemyTag>());
return q.CalculateEntityCount();
}
[Test]
public void Swarmer_Slot_SpawnsWholePack_ConsumesOneSlot()
{
var (w, g) = WaveWorld(200);
using (w)
{
var em = w.EntityManager;
var dir = MakeDirector(em, swarmerSlotBase: 1, packSize: 4, maxAlive: 12);
g.Update(); // Lull -> start wave: RemainingToSpawn = WaveSlots(1) = 1 swarmer slot
Assert.AreEqual(1, em.GetComponentData<WaveState>(dir).RemainingToSpawn, "one swarmer SLOT this wave");
g.Update(); // Spawning -> the pack lands in one tick
Assert.AreEqual(4, CountEnemies(em), "the whole pack spawns in a single tick");
var st = em.GetComponentData<WaveState>(dir);
Assert.AreEqual(1, st.SpawnCounter, "exactly ONE slot consumed for the pack");
Assert.AreEqual(0, st.RemainingToSpawn, "the swarmer slot is done");
}
}
[Test]
public void Swarmer_PackOverMaxAlive_Defers_KeepsSlot()
{
var (w, g) = WaveWorld(200);
using (w)
{
var em = w.EntityManager;
var dir = MakeDirector(em, swarmerSlotBase: 1, packSize: 4, maxAlive: 3); // pack(4) > cap(3)
g.Update(); // start wave
g.Update(); // try to spawn -> 0 + 4 > 3 -> defer (don't partially spawn, don't consume the slot)
Assert.AreEqual(0, CountEnemies(em), "a pack that won't fit MaxAlive is NOT partially spawned");
var st = em.GetComponentData<WaveState>(dir);
Assert.AreEqual(0, st.SpawnCounter, "the slot is NOT consumed when deferred");
Assert.AreEqual(1, st.RemainingToSpawn, "the swarmer slot remains pending");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 96771355c799615499f6455055b66ca8
@@ -24,16 +24,16 @@ namespace ProjectM.Tests
void Add<T>() where T : unmanaged, ISystem
=> group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
// RPC-receive systems ordered before the run director
Add<ReadyToggleSystem>(); Add<RouteSelectSystem>(); Add<PortalInteractReceiveSystem>();
Add<MetaSpendSystem>(); Add<ClassSelectReceiveSystem>(); Add<BoonApplySystem>(); Add<PrepPurchaseSystem>();
// Run director + the systems ordered around it (the cycle/siege spine is deleted — LANTERN purge)
Add<RunDirectorSystem>(); Add<RoomFieldSystem>();
Add<RoomEnemyDirectorSystem>(); Add<BoonOfferSystem>();
// RPC-receive systems + the surviving server spine. The 2026-08-07 audit purge removed
// ReadyToggle / RouteSelect / PortalInteract / MetaSpend / BoonApply / PrepPurchase / RunDirector /
// RoomField / RoomEnemyDirector / BoonOffer / BossAI / EnemyProjectile* along with the shell.
Add<ClassSelectReceiveSystem>();
Add<WaveSystem>();
// Combat sub-chain in the same group
Add<EnemyAISystem>(); Add<BossAISystem>();
Add<EnemyProjectileMoveSystem>(); Add<EnemyProjectileDamageSystem>();
Add<EnemyAISystem>();
Add<ReelSystem>();
Add<ZonePulseSystem>();
Add<LightRelevancySystem>();
Add<RegionRelevancySystem>();
Assert.DoesNotThrow(() => group.SortSystems(),
"A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation.");
@@ -43,18 +43,18 @@ namespace ProjectM.Tests
[Test]
public void PredictedCombatChain_Sorts_Without_A_Dependency_Cycle()
{
// Phase 1.7 added DashTrailDamageSystem ([UpdateAfter(DashSystem)][UpdateBefore(HealthApplyDamageSystem)])
// and KillRewardSystem ([UpdateAfter(HealthApplyDamageSystem)]) to the predicted combat chain. A cycle in
// these [UpdateBefore/After] edges is INVISIBLE to per-system fixtures — it only throws at Play world
// creation. Co-register the chain and sort to reproduce that headlessly (SortSystems only, never Update).
// A cycle in the predicted combat chain's [UpdateBefore/After] edges is INVISIBLE to per-system
// fixtures — it only throws at Play world creation. Co-register the chain and sort to reproduce that
// headlessly (SortSystems only, never Update). DashTrailDamageSystem and KillRewardSystem were
// removed from this roster with the 2026-08-07 boon purge.
using var world = new World("OrderCyclePredicted");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
void Add<T>() where T : unmanaged, ISystem
=> group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
Add<StatRecomputeSystem>(); Add<MeleeComboSystem>(); Add<DashSystem>(); Add<DashTrailDamageSystem>();
Add<StatRecomputeSystem>(); Add<MeleeComboSystem>(); Add<DashSystem>();
Add<AbilityFireSystem>(); Add<ProjectileMoveSystem>(); Add<ProjectileDamageSystem>();
Add<HealthApplyDamageSystem>(); Add<KillRewardSystem>();
Add<HealthApplyDamageSystem>();
// 07-15 facing rework: PlayerAimSystem gained [UpdateAfter(MeleeComboSystem)] (plus the existing
// StatRecompute/PlayerDeathState UpdateBefore edges) - co-register the full facing neighborhood so a
// cycle in these edges throws here instead of only at Play world-creation.
@@ -11,8 +11,7 @@ namespace ProjectM.Tests
/// <summary>
/// EditMode coverage for the MC-0/MC-1 DevTelemetry counter WIRING (the fun-gate is measured, not argued):
/// DashIFrameNegatedHits + DashState.NegatedCount (HealthApplyDamageSystem), DashesWasted (DashSystem
/// window-close edge, server-gated on the DevTelemetry singleton), and ChargerWhiffPunishesLanded
/// (player-sourced hit inside a Charger's StaggerUntilTick window, scored ONCE per window).
/// window-close edge, server-gated on the DevTelemetry singleton).
/// </summary>
public class TelemetryCountersTests
{
@@ -59,13 +58,6 @@ namespace ProjectM.Tests
return e;
}
static Entity MakeStaggeredCharger(EntityManager em, uint staggerUntil)
{
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(LungeState));
em.SetComponentData(e, new Health { Current = 200f, Max = 200f });
em.SetComponentData(e, new LungeState { StaggerUntilTick = staggerUntil });
return e;
}
[Test]
public void Negation_Increments_DevTelemetry_And_DashState_NegatedCount()
@@ -144,62 +136,8 @@ namespace ProjectM.Tests
}
}
[Test]
public void Player_Hit_On_Staggered_Charger_Scores_Punish_Once()
{
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunish", 120, withTelemetry: true);
using (world)
{
var em = world.EntityManager;
var e = MakeStaggeredCharger(em, staggerUntil: 150); // stagger window active at 120
var dmg = em.GetBuffer<DamageEvent>(e);
dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 119 }); // player hit
dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 119 }); // same drain, same window
group.Update();
Assert.AreEqual(1u, Telemetry(em).ChargerWhiffPunishesLanded,
"A stagger window counts at most ONE punish (ratio to windows-opened stays <= 1).");
Assert.AreEqual(0u, em.GetComponentData<LungeState>(e).StaggerUntilTick,
"Scoring zeroes StaggerUntilTick (the one-shot).");
Assert.AreEqual(180f, em.GetComponentData<Health>(e).Current, 1e-4f, "Both hits still apply damage.");
}
}
[Test]
public void NonPlayer_Hit_On_Staggered_Charger_Does_Not_Score()
{
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunishTurret", 120, withTelemetry: true);
using (world)
{
var em = world.EntityManager;
var e = MakeStaggeredCharger(em, staggerUntil: 150);
em.GetBuffer<DamageEvent>(e).Add(new DamageEvent { Amount = 10f, SourceNetworkId = -1, SourceTick = 119 });
group.Update();
Assert.AreEqual(0u, Telemetry(em).ChargerWhiffPunishesLanded,
"Environment/turret damage (SourceNetworkId=-1) never scores a punish.");
Assert.AreEqual(150u, em.GetComponentData<LungeState>(e).StaggerUntilTick,
"The window stays scoreable for a real player hit.");
}
}
[Test]
public void Expired_Stagger_Window_Does_Not_Score()
{
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunishLate", 200, withTelemetry: true);
using (world)
{
var em = world.EntityManager;
var e = MakeStaggeredCharger(em, staggerUntil: 150); // already over at 200
em.GetBuffer<DamageEvent>(e).Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 199 });
group.Update();
Assert.AreEqual(0u, Telemetry(em).ChargerWhiffPunishesLanded,
"A hit after the stagger window elapses is not a punish.");
}
}
// 2026-08-07 audit purge: the three ChargerWhiffPunishesLanded tests are gone with the Charger. They
// exercised HealthApplyDamageSystem's LungeState.StaggerUntilTick branch, which no baked prefab could
// ever reach. The dash-window counters above still cover the telemetry plumbing itself.
}
}
@@ -5,56 +5,41 @@ namespace ProjectM.Tests
{
/// <summary>
/// Pure-function tests for <see cref="ZoneEnemyMath"/> (no ECS world): the deterministic, save-reproducible
/// expedition-wave composition. Pins the lower bound, the per-epoch ramp, and the grunt-heavy -> charger-heavy
/// shift (grunt count held fixed; the per-epoch growth is all chargers).
/// wave size. Pins the lower bound and the per-epoch ramp.
///
/// 2026-08-07 audit purge: the IsChargerSlot / weighted-composition tests are gone with the 4-kind MixBands
/// model. They were green the whole time while the behaviour they certified could not execute — no prefab
/// carried ChargerAuthoring, so every weighted slot resolved to Grunt at runtime. That false confidence is
/// exactly what the audit flagged. Recover them from git alongside MixBands if the model returns.
/// </summary>
public class ZoneEnemyMathTests
{
[Test]
public void WaveSize_LowerBoundedAtOne()
{
Assert.AreEqual(1, ZoneEnemyMath.WaveSize(0, 0, 0), "an occupied expedition always has at least one enemy");
Assert.AreEqual(1, ZoneEnemyMath.WaveSize(-5, 0, 0), "epoch is floored at 1 internally");
Assert.AreEqual(1, ZoneEnemyMath.WaveSize(0, 0), "an occupied arena always has at least one enemy");
Assert.AreEqual(1, ZoneEnemyMath.WaveSize(-5, 0), "epoch is floored at 1 internally");
}
[Test]
public void WaveSize_BaselinePlusOnePerEpoch()
{
Assert.AreEqual(5, ZoneEnemyMath.WaveSize(1, 4, 1), "epoch 1: 4 grunts + 1 charger");
Assert.AreEqual(7, ZoneEnemyMath.WaveSize(3, 4, 1), "epoch 3: baseline 5 + (3-1) ramp");
Assert.AreEqual(5, ZoneEnemyMath.WaveSize(1, 5), "epoch 1 == the baked base count");
Assert.AreEqual(7, ZoneEnemyMath.WaveSize(3, 5), "epoch 3: baseline 5 + (3-1) ramp");
}
[Test]
public void IsChargerSlot_Epoch1_GruntsFirst_OneChargerLast()
public void WaveSize_NegativeBaseCount_StillFights()
{
// epoch 1, G=4 C=1 -> size 5, only the last slot is a charger.
Assert.IsFalse(ZoneEnemyMath.IsChargerSlot(1, 0, 4, 1));
Assert.IsFalse(ZoneEnemyMath.IsChargerSlot(1, 3, 4, 1));
Assert.IsTrue(ZoneEnemyMath.IsChargerSlot(1, 4, 4, 1));
Assert.AreEqual(1, ZoneEnemyMath.WaveSize(1, -4), "a mis-authored negative base count still fights");
}
[Test]
public void Composition_GruntCountFixed_ChargerShareGrowsWithEpoch()
public void WaveSize_Deterministic()
{
AssertComposition(epoch: 1, grunts: 4, chargers: 1, expectGrunts: 4, expectChargers: 1);
AssertComposition(epoch: 5, grunts: 4, chargers: 1, expectGrunts: 4, expectChargers: 5);
}
static void AssertComposition(int epoch, int grunts, int chargers, int expectGrunts, int expectChargers)
{
int size = ZoneEnemyMath.WaveSize(epoch, grunts, chargers);
int g = 0, c = 0;
for (int slot = 0; slot < size; slot++)
if (ZoneEnemyMath.IsChargerSlot(epoch, slot, grunts, chargers)) c++; else g++;
Assert.AreEqual(expectGrunts, g, $"grunt count at epoch {epoch}");
Assert.AreEqual(expectChargers, c, $"charger count at epoch {epoch}");
}
[Test]
public void IsChargerSlot_Deterministic()
{
for (int slot = 0; slot < 9; slot++)
Assert.AreEqual(ZoneEnemyMath.IsChargerSlot(5, slot, 4, 1), ZoneEnemyMath.IsChargerSlot(5, slot, 4, 1));
for (int epoch = 1; epoch < 9; epoch++)
Assert.AreEqual(ZoneEnemyMath.WaveSize(epoch, 5), ZoneEnemyMath.WaveSize(epoch, 5),
"same inputs must give the same wave size — a replayed wave is identical");
}
}
}
@@ -1,91 +0,0 @@
using NUnit.Framework;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// MC-2 pure-math tests for the 4-type weighted composition (ZoneEnemyMath.WaveSlots / KindForSlot /
/// PackSizeForSlot) shared by both enemy directors. Deterministic integer math (no ECS world). The PARITY test
/// pins that the legacy band reproduces the old 2-type WaveSize/IsChargerSlot EXACTLY, so the base-siege size +
/// composition is provably controlled where it must be (the fork-4a safety net).
/// </summary>
public class ZoneEnemyMixTests
{
static MixBands Bands(int g, int c, int sp, int sw, int cPer, int spPer, int swPer, int packPer = 0) => new MixBands
{
GruntBase = g, ChargerBase = c, SpitterBase = sp, SwarmerSlotBase = sw,
ChargerPerEpoch = cPer, SpitterPerEpoch = spPer, SwarmerSlotPerEpoch = swPer, SwarmerPackPerEpoch = packPer,
};
[Test]
public void WaveSlots_LowerBoundedAtOne_AndSumsTheBands()
{
Assert.AreEqual(1, ZoneEnemyMath.WaveSlots(1, Bands(0, 0, 0, 0, 0, 0, 0)), "empty band still yields a fight");
Assert.AreEqual(5, ZoneEnemyMath.WaveSlots(1, Bands(4, 1, 0, 0, 1, 0, 0)), "4 grunts + 1 charger at epoch 1");
Assert.AreEqual(7, ZoneEnemyMath.WaveSlots(3, Bands(4, 1, 0, 0, 1, 0, 0)), "epoch 3: +1 charger/epoch -> 4+(1+2)");
Assert.AreEqual(4 + 2 + 1 + 1, ZoneEnemyMath.WaveSlots(2, Bands(4, 1, 0, 0, 1, 1, 1)), "epoch 2: 4 grunts + 2 chargers + 1 spitter + 1 swarmer-slot");
}
[Test]
public void KindForSlot_Deterministic()
{
var b = Bands(4, 1, 1, 1, 1, 1, 1);
for (int slot = 0; slot < 30; slot++)
Assert.AreEqual(ZoneEnemyMath.KindForSlot(5, slot, b), ZoneEnemyMath.KindForSlot(5, slot, b), "stable per (epoch,slot)");
}
[Test]
public void KindForSlot_GruntFloorFixed_ThreatsGrowWithEpoch()
{
var b = Bands(4, 1, 0, 0, 1, 0, 0); // grunts fixed at 4, chargers grow
CountKinds(b, 1, out int g1, out int c1, out int _, out int _);
Assert.AreEqual(4, g1); Assert.AreEqual(1, c1);
CountKinds(b, 5, out int g5, out int c5, out int _, out int _);
Assert.AreEqual(4, g5, "grunt count is a fixed floor"); Assert.AreEqual(5, c5, "chargers = base + (epoch-1)");
}
[Test]
public void KindForSlot_ParityWithLegacyIsChargerSlot()
{
for (int g = 0; g <= 6; g++)
for (int c = 0; c <= 4; c++)
for (int e = 1; e <= 6; e++)
{
var b = Bands(g, c, 0, 0, 1, 0, 0); // legacy band: charger ramps +1/epoch, no spitter/swarmer
int size = ZoneEnemyMath.WaveSlots(e, b);
Assert.AreEqual(ZoneEnemyMath.WaveSize(e, g, c), size, $"WaveSlots vs WaveSize g{g} c{c} e{e}");
for (int slot = 0; slot < size + 3; slot++)
{
bool legacy = ZoneEnemyMath.IsChargerSlot(e, slot, g, c);
bool now = ZoneEnemyMath.KindForSlot(e, slot, b) == ZoneEnemyMath.KindCharger;
Assert.AreEqual(legacy, now, $"parity g{g} c{c} e{e} slot{slot}");
}
}
}
[Test]
public void PackSizeForSlot_FixedByDefault_RampsWhenSet()
{
var fixedBand = Bands(0, 0, 0, 1, 0, 0, 1);
Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 4), "base pack");
Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(5, 0, fixedBand, 4), "no ramp -> fixed across epochs");
var rampBand = Bands(0, 0, 0, 1, 0, 0, 1, packPer: 2);
Assert.AreEqual(4 + 2 * 2, ZoneEnemyMath.PackSizeForSlot(3, 0, rampBand, 4), "epoch 3 ramp +2*(3-1)");
Assert.GreaterOrEqual(ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 0), 1, "lower-bounded at 1");
}
static void CountKinds(MixBands b, int epoch, out int g, out int c, out int sp, out int sw)
{
g = c = sp = sw = 0;
int size = ZoneEnemyMath.WaveSlots(epoch, b);
for (int slot = 0; slot < size; slot++)
{
byte k = ZoneEnemyMath.KindForSlot(epoch, slot, b);
if (k == ZoneEnemyMath.KindGrunt) g++;
else if (k == ZoneEnemyMath.KindCharger) c++;
else if (k == ZoneEnemyMath.KindSpitter) sp++;
else sw++;
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6853067864d6bc342bac525cdee324f8