using AcDream.Core.Items; using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Tests.Gameplay; /// /// Slice 6.1 — in isolation: the /// diff/materialize/retire logic against a bare + /// pair, independent of the wire parse /// ( covers the end-to-end /// ApproachVendor path). /// public sealed class VendorShopItemMaterializerTests { private const uint VendorGuid = 0x40001000u; private const uint OtherVendorGuid = 0x40002000u; private const uint ItemA = 0x50002000u; private const uint ItemB = 0x50002001u; private static VendorShopItem Item(uint guid, string name = "Item", int? descStackSize = null) => new(guid, StackSize: -1, WeenieClassId: 1u, Name: name, ItemType: (uint)ItemType.Misc, IconId: 0x1234u, Value: 10, DescStackSize: descStackSize); [Fact] public void Apply_MaterializesEachShopItemWithVendorAsContainer() { var vendor = new VendorState(); var objects = new ClientObjectTable(); using var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Iron Sword"), Item(ItemB, "Bread") }); ClientObject? a = objects.Get(ItemA); ClientObject? b = objects.Get(ItemB); Assert.NotNull(a); Assert.NotNull(b); Assert.Equal(VendorGuid, a!.ContainerId); Assert.Equal(VendorGuid, b!.ContainerId); Assert.Equal("Iron Sword", a.Name); Assert.Equal(2, materializer.OwnedCount); Assert.True(materializer.Owns(ItemA)); Assert.True(materializer.Owns(ItemB)); } [Fact] public void Close_RemovesEveryMaterializedItem() { var vendor = new VendorState(); var objects = new ClientObjectTable(); using var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { Item(ItemA), Item(ItemB) }); vendor.Close(); Assert.Null(objects.Get(ItemA)); Assert.Null(objects.Get(ItemB)); Assert.Equal(0, materializer.OwnedCount); } [Fact] public void Reset_RemovesEveryMaterializedItem() { var vendor = new VendorState(); var objects = new ClientObjectTable(); using var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { Item(ItemA) }); vendor.Reset(); Assert.Null(objects.Get(ItemA)); Assert.Equal(0, materializer.OwnedCount); } [Fact] public void DifferentVendorSupersedes_RemovesPriorVendorsItemsBeforeMaterializingTheNewOnes() { var vendor = new VendorState(); var objects = new ClientObjectTable(); using var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "First Vendor Item") }); Assert.NotNull(objects.Get(ItemA)); const uint NewItem = 0x50003000u; vendor.Apply(OtherVendorGuid, default, new[] { Item(NewItem, "Second Vendor Item") }); Assert.Null(objects.Get(ItemA)); ClientObject? replacement = objects.Get(NewItem); Assert.NotNull(replacement); Assert.Equal(OtherVendorGuid, replacement!.ContainerId); Assert.Equal(1, materializer.OwnedCount); } [Fact] public void Refreshed_SameVendor_DoesNotFireObjectRemovedForStillListedItems() { // A same-vendor re-approach (post-buy refresh) must not remove+ // re-add a guid that's still in stock -- see the class doc's "diff, // not blanket remove-then-reinsert" rationale. A UI panel holding // the guid (an open appraisal window) would see a false "it's gone" // notice if this regressed to blanket removal. var vendor = new VendorState(); var objects = new ClientObjectTable(); using var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Chainmail") }); var removed = new List(); objects.ObjectRemoved += o => removed.Add(o.ObjectId); // Same vendor id re-approaches with the SAME item guid still listed // (e.g. a post-buy refresh where this item wasn't the one bought) // but with a refreshed field value. vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Chainmail", descStackSize: 5) }); Assert.Empty(removed); Assert.Equal(1, materializer.OwnedCount); Assert.Equal(5, objects.Get(ItemA)!.StackSize); } [Fact] public void Refreshed_ItemNoLongerListed_IsRemoved() { // An item drops out of the vendor's own list WITHOUT ever being // re-containered elsewhere (e.g. admin-removed stock, a delisted // line item) -- its live ContainerId is still the vendor's own // guid, so the retire pass's F1 ownership re-check finds a match // and removes it. This is deliberately NOT "I bought it" -- see // Refreshed_ItemPurchased_ReparentedIntoBuyerPack_Survives for that // case, where the SAME "missing from the new snapshot" trigger must // NOT delete the item because a real purchase already moved it into // the buyer's own pack before this refresh arrived. var vendor = new VendorState(); var objects = new ClientObjectTable(); using var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { Item(ItemA), Item(ItemB) }); vendor.Apply(VendorGuid, default, new[] { Item(ItemB) }); Assert.Null(objects.Get(ItemA)); Assert.NotNull(objects.Get(ItemB)); Assert.Equal(1, materializer.OwnedCount); } [Fact] public void Refreshed_ItemPurchased_ReparentedIntoBuyerPack_Survives() { // F1: buying a UNIQUE vendor item does not merely drop it from the // next ApproachVendor snapshot -- ACE first re-containers the SAME // guid into the BUYER's own pack via CreateObject // (Player_Commerce.cs:86-108) and only THEN sends the full-replace // refresh that no longer lists it. The retire pass must re-check // live ownership (ContainerId) before deleting, or it strips the // just-purchased item straight back out of the buyer's inventory. var vendor = new VendorState(); var objects = new ClientObjectTable(); using var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Unique Sword"), Item(ItemB) }); Assert.Equal(VendorGuid, objects.Get(ItemA)!.ContainerId); const uint BuyerGuid = 0x50009000u; // Simulate the purchase's CreateObject: ACE re-containers the SAME // guid into the buyer's pack BEFORE the post-buy ApproachVendor // refresh arrives. objects.Ingest(new WeenieData( Guid: ItemA, Name: null, Type: null, WeenieClassId: 0, IconId: 0, IconOverlayId: 0, IconUnderlayId: 0, Effects: 0, Value: null, StackSize: null, StackSizeMax: null, Burden: null, ContainerId: BuyerGuid, WielderId: null, ValidLocations: null, CurrentWieldedLocation: null, Priority: null, ItemsCapacity: null, ContainersCapacity: null, Structure: null, MaxStructure: null, Workmanship: null)); Assert.Equal(BuyerGuid, objects.Get(ItemA)!.ContainerId); // Post-buy ApproachVendor refresh: the purchased item is gone from // the shop's own list. vendor.Apply(VendorGuid, default, new[] { Item(ItemB) }); ClientObject? survivor = objects.Get(ItemA); Assert.NotNull(survivor); Assert.Equal(BuyerGuid, survivor!.ContainerId); Assert.False(materializer.Owns(ItemA)); Assert.Equal(1, materializer.OwnedCount); } [Fact] public void CollidingGuid_AlreadyOwnedBySomethingElse_IsNeverClobbered() { // The Slice 6.1 collision policy: ACE's UniqueItemsForSale can list // the EXACT guid a player last held (e.g. a sold-off item, or -- // worst case -- any other collision). If that guid is already in // ClientObjectTable for a reason this materializer did not itself // create, it must be left completely untouched, not silently // reparented into the vendor's container. var vendor = new VendorState(); var objects = new ClientObjectTable(); // Simulate a pre-existing, non-vendor-owned object at this guid -- // e.g. a live entity, or an item still sitting in someone's // inventory/equipment. const uint LiveOwner = 0x60000001u; objects.AddOrUpdate(new ClientObject { ObjectId = ItemA, Name = "Definitely Not A Shop Item", ContainerId = LiveOwner, }); using var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Shop Listing With A Colliding Guid") }); ClientObject? survivor = objects.Get(ItemA); Assert.NotNull(survivor); Assert.Equal("Definitely Not A Shop Item", survivor!.Name); Assert.Equal(LiveOwner, survivor.ContainerId); Assert.False(materializer.Owns(ItemA)); Assert.Equal(0, materializer.OwnedCount); // The collision guid must also survive session close -- since this // materializer never claimed it, it must never remove it either. vendor.Close(); Assert.NotNull(objects.Get(ItemA)); } [Fact] public void Dispose_UnsubscribesFromVendorChanged() { var vendor = new VendorState(); var objects = new ClientObjectTable(); var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { Item(ItemA) }); Assert.NotNull(objects.Get(ItemA)); materializer.Dispose(); // No further reaction once disposed -- a Close() after disposal // must not throw and must not touch the table (nothing left // subscribed to react). vendor.Close(); Assert.NotNull(objects.Get(ItemA)); } [Fact] public void Retire_ThrowingObjectRemovedObserver_StillRetiresRemainingGuidsAndConverges() { // F5: ClientObjectTable.Remove fires ObjectRemoved with NO // per-listener isolation (unlike VendorState's own Changed // dispatch). One throwing observer must not abort the retire loop // partway through and strand _ownedGuids -- every owned guid still // retires, and OwnedCount still converges to zero. var vendor = new VendorState(); var objects = new ClientObjectTable(); using var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { Item(ItemA), Item(ItemB) }); Assert.Equal(2, materializer.OwnedCount); // A throwing ObjectRemoved observer fires on EVERY Remove() call // (both ItemA's and ItemB's) -- multicast delegate invocation has // no per-listener isolation, so each Remove() call itself throws. objects.ObjectRemoved += _ => throw new InvalidOperationException("boom"); vendor.Close(); // Both guids are gone from the table -- the throwing first observer // did not stop the second guid's Remove() call from happening. Assert.Null(objects.Get(ItemA)); Assert.Null(objects.Get(ItemB)); Assert.Equal(0, materializer.OwnedCount); Assert.False(materializer.Owns(ItemA)); Assert.False(materializer.Owns(ItemB)); // Dispose still converges cleanly afterward. materializer.Dispose(); Assert.Equal(0, materializer.OwnedCount); } }