diff --git a/Assets/_Project/Tests/EditMode/EquipSystemTests.cs b/Assets/_Project/Tests/EditMode/EquipSystemTests.cs new file mode 100644 index 000000000..e62db0bff --- /dev/null +++ b/Assets/_Project/Tests/EditMode/EquipSystemTests.cs @@ -0,0 +1,285 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Core; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for the server-only . Seeds a player + /// (GhostOwner + PlayerTag + InventorySlot + EquipmentSlot[4 rows] + StatModifier + AbilityRef + + /// DefaultAbility), an inline-built ItemDatabase singleton, a mock connection, and an Equip/Unequip RPC. + /// Pins: weapon-equip sets AbilityRef + adds the slot-tagged mod + moves the item bag->slot; unequip reverses + /// and restores DefaultAbility; 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. + /// + public class EquipSystemTests + { + const ushort WeaponA = 100, WeaponB = 101, GearArmor = 110, Ore = 2; + BlobAssetReference _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, byte ability, ItemModSpec m0) + { + int stackMax = slot <= EquipSlotId.Tool ? 1 : 999; + return new ItemDefBlob + { + ItemId = id, Category = 0, Tier = 0, StackMax = stackMax, + EquipSlot = slot, GrantedAbilityId = ability, + Mod0 = m0, Mod1 = NoMod(), Mod2 = NoMod(), Mod3 = NoMod(), + }; + } + + (World world, SimulationSystemGroup group) 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 builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var arr = builder.Allocate(ref root.Items, 4); + arr[0] = Mk(WeaponA, EquipSlotId.Weapon, (byte)AbilityId.FastLight, new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 5f }); + arr[1] = Mk(WeaponB, EquipSlotId.Weapon, (byte)AbilityId.SlowHeavy, new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 9f }); + arr[2] = Mk(GearArmor, EquipSlotId.Armor, 0, new ItemModSpec { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentAdd, Value = 0.1f }); + arr[3] = Mk(Ore, EquipSlotId.None, 0, NoMod()); + _blob = builder.CreateBlobAssetReference(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(e); + em.AddComponentData(e, new AbilityRef { Id = (byte)AbilityId.Primary }); + em.AddComponentData(e, new DefaultAbility { Id = (byte)AbilityId.Primary }); + var bag = em.AddBuffer(e); + foreach (var it in bagItems) bag.Add(new InventorySlot { ItemId = it.id, Count = it.count }); + var slots = em.AddBuffer(e); + for (int s = 0; s < EquipSlotId.Count; s++) slots.Add(new EquipmentSlot { ItemId = 0 }); + em.AddBuffer(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 byte Ability(EntityManager em, Entity p) => em.GetComponentData(p).Id; + static ushort Slot(EntityManager em, Entity p, byte slot) => em.GetBuffer(p)[slot].ItemId; + static int Bag(EntityManager em, Entity p, ushort id) => InventoryMath.CountOf(em.GetBuffer(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(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(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_Sets_Ability_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((byte)AbilityId.FastLight, Ability(em, player), "The weapon grants its ability into AbilityRef."); + 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_Restores_Default_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((byte)AbilityId.Primary, Ability(em, player), "Unequip restores the DefaultAbility."); + 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((byte)AbilityId.SlowHeavy, Ability(em, player), "AbilityRef swaps to weapon B's ability."); + 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(player); + preSlots[EquipSlotId.Weapon] = new EquipmentSlot { ItemId = WeaponA }; + var bag = em.GetBuffer(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((byte)AbilityId.Primary, Ability(em, player), "AbilityRef unchanged."); + 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(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."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/EquipSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/EquipSystemTests.cs.meta new file mode 100644 index 000000000..5aac4816a --- /dev/null +++ b/Assets/_Project/Tests/EditMode/EquipSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5aa8cc2d95b243d49b7acb4c184df7f2 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs b/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs new file mode 100644 index 000000000..e1e703bac --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs @@ -0,0 +1,55 @@ +using NUnit.Framework; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Entities; + +namespace ProjectM.Tests +{ + /// + /// Regression guard for the Phase 1 inline-mod design: returns the + /// def BY VALUE, so the inline 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. + /// + 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(); + 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(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(); } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs.meta b/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs.meta new file mode 100644 index 000000000..cd7bae077 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ItemDatabaseBlobTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4933b5824e154374494e80fa6ceaa81c \ No newline at end of file