using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; namespace ProjectM.Tests { /// /// 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. /// 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(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(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("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(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(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("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(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("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(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(dir), ResourceId.Ore), "No second withdraw."); } } [Test] public void Non_Staging_Is_Rejected() { var (world, group) = TestWorld.Make("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(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("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(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("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(dir), ResourceId.Aether), "Aether withdrawn once; never negative."); Assert.AreEqual(0, OpenRequests(em), "Both requests consumed."); } } } }