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:
@@ -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 -> 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 -> 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 < 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
|
||||
Reference in New Issue
Block a user