From e23bebc84b37a6892c8f995799b61334a30d86c2 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 8 Jun 2026 09:43:47 -0700 Subject: [PATCH] Tests: inventory EditMode coverage (DR-026) InventoryMathTests (stack/slot-cap/withdraw/CountOf); InventoryHarvestTests (owned harvest -> inventory with ledger untouched, full-bag spill, no-matching-player -> ledger fallback); InventoryDepositSystemTests (specific/all/unresolvable). The 8 existing ResourceHarvestSystemTests stay green via the genuine no-owner fallback. 228/228 EditMode pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EditMode/InventoryDepositSystemTests.cs | 131 ++++++++++++++++ .../InventoryDepositSystemTests.cs.meta | 2 + .../Tests/EditMode/InventoryHarvestTests.cs | 141 ++++++++++++++++++ .../EditMode/InventoryHarvestTests.cs.meta | 2 + .../Tests/EditMode/InventoryMathTests.cs | 89 +++++++++++ .../Tests/EditMode/InventoryMathTests.cs.meta | 2 + 6 files changed, 367 insertions(+) create mode 100644 Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs create mode 100644 Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs create mode 100644 Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/InventoryMathTests.cs create mode 100644 Assets/_Project/Tests/EditMode/InventoryMathTests.cs.meta diff --git a/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs b/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs new file mode 100644 index 000000000..216b9465b --- /dev/null +++ b/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs @@ -0,0 +1,131 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Core; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for the server-only — the RPC that + /// moves a player's PERSONAL inventory into the shared ledger. Mirrors RegionTransitSystemTests' 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. + /// + public class InventoryDepositSystemTests + { + static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + var ledger = em.CreateEntity(typeof(ResourceLedger)); + em.AddBuffer(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(e); + var bag = em.AddBuffer(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(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(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."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs.meta new file mode 100644 index 000000000..3d1127cf0 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/InventoryDepositSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0a222e00ad08444793bf5b1ffccc71a \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs b/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs new file mode 100644 index 000000000..c742174b8 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs @@ -0,0 +1,141 @@ +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 +{ + /// + /// Plain-Entities EditMode tests for the harvest -> PERSONAL inventory reroute in + /// . 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 pin the un-owned -> ledger fallback. + /// + public class InventoryHarvestTests + { + static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + var ledger = em.CreateEntity(typeof(ResourceLedger)); + em.AddBuffer(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(e); + em.AddBuffer(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(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(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(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(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(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)); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs.meta b/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs.meta new file mode 100644 index 000000000..941ac28f1 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/InventoryHarvestTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 42fed61a7f84fd740b17ec2c4cff8204 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/InventoryMathTests.cs b/Assets/_Project/Tests/EditMode/InventoryMathTests.cs new file mode 100644 index 000000000..ffb22ebf9 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/InventoryMathTests.cs @@ -0,0 +1,89 @@ +using NUnit.Framework; +using ProjectM.Simulation; +using Unity.Entities; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for the pure 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 + /// buffer (a DynamicBuffer needs an entity); no systems run. + /// + public class InventoryMathTests + { + static (World world, DynamicBuffer buffer) MakeBuffer() + { + var world = new World("InventoryMathTest"); + var em = world.EntityManager; + var e = em.CreateEntity(); + var buffer = em.AddBuffer(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."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/InventoryMathTests.cs.meta b/Assets/_Project/Tests/EditMode/InventoryMathTests.cs.meta new file mode 100644 index 000000000..39d7f04ac --- /dev/null +++ b/Assets/_Project/Tests/EditMode/InventoryMathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dbdfefb1a0acad849b84fadfb2938d9e \ No newline at end of file