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); } /// /// R1 gate-finding fix (2026-08-08, register AP-169 correction, /// replacing the retired Apply_NoDescStackSize_FallsBackToPackedSupplyCount /// G2 test below). A REAL ACE vendor listing for an UNLIMITED-STOCK /// stackable item (e.g. a Prismatic Taper) carries /// DescStackSize=null (ACE's Vendor.LoadInventoryItem never /// calls wo.SetStackSize on the browse-list WorldObject) AND the /// packed supply-count field at /// its UNLIMITED sentinel (-1) — so neither wire "how many" /// signal names a usable ceiling. The G2 fix's packed-supply-count /// fallback (the ORIGINAL version of this test) produced nothing usable /// for exactly this case — a live re-test confirmed the toolbar split /// bar stayed hidden, matching the live retail screenshot report (see /// AP-169's corrected story). This pins the CORRECT fallback: /// — the item TYPE's authored /// stack ceiling, which ACE DOES reliably populate (an ordinary weenie /// property, not an instance-specific "how many for sale" count) — no /// DescStackSize, packed StackSize=-1 (unlimited), /// MaxStackSize=1000 -> ClientObject.StackSize resolves /// to 1000, not 1 and not the (nonsensical, unbounded) packed field. /// [Fact] public void Apply_UnlimitedStockNoDescStackSize_FallsBackToMaxStackSize() { var vendor = new VendorState(); var objects = new ClientObjectTable(); using var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { new VendorShopItem( ItemA, StackSize: -1, WeenieClassId: 1u, Name: "Prismatic Taper", ItemType: (uint)ItemType.SpellComponents, IconId: 0x1234u, Value: 100, DescStackSize: null, MaxStackSize: 1000), }); ClientObject item = objects.Get(ItemA)!; Assert.Equal(1000, item.StackSize); Assert.Equal(1000, item.StackSizeMax); } /// /// Sabotage-adjacent control: the SAME unlimited-stock listing but with /// ALSO absent (neither wire /// "how many" signal usable at all) still degrades to the safe /// non-splittable default (1) rather than literally propagating the /// packed field's -1 sentinel (which would read as a huge /// unsigned "stack size" to SelectedObjectController's /// stackSize > 1 gate). Proves /// 's /// 1000 result comes from the MaxStackSize field specifically, not from /// some other code path that would resolve to 1000 regardless. /// [Fact] public void Apply_UnlimitedSupplySentinel_FallsBackToNonSplittableDefault() { var vendor = new VendorState(); var objects = new ClientObjectTable(); using var materializer = new VendorShopItemMaterializer(vendor, objects); vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Bread") }); // StackSize: -1, DescStackSize: null, MaxStackSize: null Assert.Equal(1, 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); } }