using System.Buffers.Binary; using System.Text; using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Spells; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Tests.Gameplay; /// /// Slice 5.3 — Runtime ownership of the vendor browse session: /// is populated by the inbound /// ApproachVendor (0x0062) route (GameEventWiring.WireAll's /// vendor parameter) and torn down by the shared generation-reset /// mechanism (session reset / portal-out / logout). /// public sealed class RuntimeVendorLifecycleTests { private const uint Player = 0x50000001u; [Fact] public void ApproachVendorEvent_PopulatesVendorStateWithProfileAndItems() { using GameRuntime runtime = Create(); VendorState vendor = runtime.InventoryOwner.Vendor; using IDisposable wiring = Wire(vendor); Dispatch(BuildApproachVendorPayload( vendorGuid: 0x40001000u, categories: 0x42u, minValue: 5u, maxValue: 500u, dealsMagic: true, buyPrice: 0.8f, sellPrice: 1.3f, currencyWcid: 0u, currencyAmount: 0u, currencyName: "", items: [ new VendorItemFixture( ItemGuid: 0x50002000u, StackSize: 1, Name: "Iron Sword", WeenieClassId: 42u, RawIconId: 0x1234u, ItemType: (uint)ItemType.Weapon, Value: 250), ])); Assert.Equal(0x40001000u, vendor.VendorId); // Profile fields — including the price-relevant rates (research doc // §B.1/§A.2: BuyPrice/SellPrice feed ShopSystem::BuyPrice/SellPrice). Assert.Equal(0x42u, vendor.Profile.MerchandiseItemTypes); Assert.Equal(5u, vendor.Profile.MerchandiseMinValue); Assert.Equal(500u, vendor.Profile.MerchandiseMaxValue); Assert.True(vendor.Profile.DealMagicalItems); Assert.Equal(0.8f, vendor.Profile.BuyPrice); Assert.Equal(1.3f, vendor.Profile.SellPrice); VendorShopItem item = Assert.Single(vendor.Items); Assert.Equal(0x50002000u, item.ItemGuid); Assert.Equal(1, item.StackSize); Assert.Equal("Iron Sword", item.Name); Assert.Equal(42u, item.WeenieClassId); Assert.Equal((uint)ItemType.Weapon, item.ItemType); Assert.Equal(0x1234u | CreateObject.IconTypePrefix, item.IconId); // Value is the price-relevant field: it feeds VendorPricing's // BuyPrice/SellPrice formula alongside the profile rates above. Assert.Equal(250, item.Value); } [Fact] public void SecondApproachVendorEventFromDifferentVendor_ReplacesTheSession() { using GameRuntime runtime = Create(); VendorState vendor = runtime.InventoryOwner.Vendor; using IDisposable wiring = Wire(vendor); var kinds = new List(); vendor.Changed += t => kinds.Add(t.Kind); Dispatch(BuildApproachVendorPayload( vendorGuid: 0x40001000u, categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false, buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u, currencyName: "", items: [ new VendorItemFixture( 0x50002000u, 1, "First Vendor Item", 1u, 1u, (uint)ItemType.Misc, 10), ])); Assert.Equal(0x40001000u, vendor.VendorId); Assert.Single(vendor.Items); Dispatch(BuildApproachVendorPayload( vendorGuid: 0x40002000u, categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false, buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u, currencyName: "", items: [ new VendorItemFixture( 0x50003000u, 1, "Second Vendor Item A", 2u, 2u, (uint)ItemType.Misc, 20), new VendorItemFixture( 0x50003001u, 1, "Second Vendor Item B", 3u, 3u, (uint)ItemType.Misc, 30), ])); // Replaced, not merged: the second vendor's own guid and item list // only, the first vendor's items are gone. Assert.Equal(0x40002000u, vendor.VendorId); Assert.Equal(2, vendor.Items.Count); Assert.DoesNotContain( vendor.Items, i => i.ItemGuid == 0x50002000u); Assert.Contains( vendor.Items, i => i.Name == "Second Vendor Item A"); // Both opens are a DIFFERENT vendor id, so both are "Opened" — never // "Refreshed" (that kind is reserved for a same-guid re-approach, // e.g. a Slice 6 post-buy/sell snapshot refresh). Assert.Equal( [VendorStateTransitionKind.Opened, VendorStateTransitionKind.Opened], kinds); } [Fact] public void MalformedApproachVendorEvent_IsDroppedWithoutStateChange() { using GameRuntime runtime = Create(); VendorState vendor = runtime.InventoryOwner.Vendor; using IDisposable wiring = Wire(vendor); Dispatch(BuildApproachVendorPayload( vendorGuid: 0x40001000u, categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false, buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u, currencyName: "", items: [ new VendorItemFixture( 0x50002000u, 1, "Untouched Item", 1u, 1u, (uint)ItemType.Misc, 10), ])); Assert.Equal(0x40001000u, vendor.VendorId); // Two bytes cannot even hold the vendor guid's own u32 — TryParse's // outer catch returns null and the handler's `if (p is null) return;` // drops it silently (matches every sibling handler in // GameEventWiring.cs — see the comment on the ApproachVendor // registration). Dispatch(new byte[] { 1, 2 }); Assert.Equal(0x40001000u, vendor.VendorId); Assert.Single(vendor.Items); Assert.Equal("Untouched Item", vendor.Items[0].Name); } [Fact] public void ResetGeneration_ClearsTheOpenVendorSession() { // Covers session reset / portal-out / logout uniformly: research // doc §C.2 confirms these all funnel through ONE Runtime mechanism // (RuntimeGenerationReset's staged transaction), the same single // call site ExternalContainerState's own reset already shares. using GameRuntime runtime = Create(); runtime.PlayerIdentity.ServerGuid = Player; Assert.True(runtime.InventoryOwner.Vendor.Apply( 0x40001000u, default, Array.Empty())); Assert.Equal(0x40001000u, runtime.InventoryOwner.Vendor.VendorId); runtime.ResetGeneration(new RuntimeGenerationToken(1), new NoOpResetHost()); Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId); Assert.Empty(runtime.InventoryOwner.Vendor.Items); } [Fact] public void DisposingInventoryOwner_ClearsTheOpenVendorSession() { // The other teardown edge: final GameRuntime disposal (as opposed // to an in-place generation reset) also converges Vendor to closed, // per RuntimeInventoryState.Dispose's fixed-order child reset. using GameRuntime runtime = Create(); Assert.True(runtime.InventoryOwner.Vendor.Apply( 0x40001000u, default, Array.Empty())); runtime.InventoryOwner.Dispose(); Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId); } [Fact] public void ApproachVendorEvent_MaterializesShopItemsIntoTheOwnedObjectTable() { // Slice 6.1: unlike Wire()'s throwaway ClientObjectTable (used by // the tests above, which only assert on VendorState itself), this // wires the dispatcher against the SAME table // RuntimeInventoryState.Objects exposes, so the // VendorShopItemMaterializer subscription RuntimeInventoryState's // constructor installs is exercised end-to-end from the real wire // parse through to ClientObjectTable. using GameRuntime runtime = Create(); using IDisposable wiring = GameEventWiring.WireAll( _dispatcher, runtime.InventoryOwner.Objects, new CombatState(), new Spellbook(), new ChatLog(), vendor: runtime.InventoryOwner.Vendor); Dispatch(BuildApproachVendorPayload( vendorGuid: 0x40001000u, categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false, buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u, currencyName: "", items: [ new VendorItemFixture( 0x50002000u, 1, "Iron Sword", 42u, 0x1234u, (uint)ItemType.Weapon, 250), ])); ClientObject? shopItem = runtime.InventoryOwner.Objects.Get(0x50002000u); Assert.NotNull(shopItem); Assert.Equal(0x40001000u, shopItem!.ContainerId); Assert.Equal("Iron Sword", shopItem.Name); Assert.Equal(1, runtime.InventoryOwner.VendorItems.OwnedCount); runtime.InventoryOwner.Vendor.Close(); Assert.Null(runtime.InventoryOwner.Objects.Get(0x50002000u)); Assert.Equal(0, runtime.InventoryOwner.VendorItems.OwnedCount); // Close() is a client-local session end, not full disposal, so only // the vendor-specific ownership dimensions are asserted here — the // full IsConverged gate is exercised by DisposingInventoryOwner_ // ClearsTheOpenVendorSession below. RuntimeInventoryOwnershipSnapshot snapshot = runtime.InventoryOwner.CaptureOwnership(); Assert.Equal(0u, snapshot.VendorId); Assert.Equal(0, snapshot.MaterializedVendorItemCount); } [Fact] public void SecondUse_AfterLocalXClose_StillDispatchesOverTheWireAndReopensOnReapproach() { // G4 (vendor gate finding, client-side half): the user reported that // after X-closing the vendor panel (VendorState.Close() -- a PURE // client-local UI teardown per research doc A.3/A.4, no wire send), // using the vendor again does nothing. This isolates the Runtime // machinery a repeat Use actually depends on -- // RuntimeInteractionTransactionState's reservation/busy-count gate // and VendorState's own open/close bookkeeping -- with NO App-layer // world-picking in the loop (AcDream.Runtime.Tests cannot reference // AcDream.App). If this passes, the suppression (if real) is NOT in // Runtime; it would have to be in the App-layer picking/identity // chain (WorldSelectionQuery/SelectionInteractionController), which // needs its own harness to confirm or rule out. using GameRuntime runtime = Create(); VendorState vendor = runtime.InventoryOwner.Vendor; using IDisposable wiring = Wire(vendor); RuntimeInteractionTransactionState transactions = runtime.ActionOwner.Transactions; var transport = new FakeTransport(); const uint vendorGuid = 0x40001000u; // --- First open: press Use -> dispatch -> ApproachVendor arrives -> // UseDone arrives (ACE's Player_Use.TryUseItem ALWAYS schedules // SendUseDoneEvent() after ActOnUse returns, since Vendor.ActOnUse // never sets LastUseTime = float.MinValue -- confirmed against // references/ACE/Source/ACE.Server/WorldObjects/{Vendor,Player_Use}.cs). ItemUseRequestReservation reservation1 = transactions.BeginUseRequestReservation(); RuntimeInteractionDispatchResult result1 = transactions.TryDispatchUse( vendorGuid, ownedByPlayer: false, useable: true, reservation1, transport, out _); Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result1); Assert.Equal(new[] { vendorGuid }, transport.Uses); Assert.Equal(1, transactions.Inventory.BusyCount); Dispatch(BuildApproachVendorPayload( vendorGuid: vendorGuid, categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false, buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u, currencyName: "", items: [])); Assert.Equal(vendorGuid, vendor.VendorId); transactions.CompleteUse(0u); Assert.Equal(0, transactions.Inventory.BusyCount); // --- X-close: the ONLY thing our client does locally. Assert.True(vendor.Close()); Assert.Equal(0u, vendor.VendorId); // --- Second Use attempt on the SAME vendor guid, well past the // 200ms retail throttle (irrelevant here since TryDispatchUse has no // throttle of its own -- that lives one layer up in // ItemInteractionController/App -- but asserted for clarity). ItemUseRequestReservation reservation2 = transactions.BeginUseRequestReservation(); RuntimeInteractionDispatchResult result2 = transactions.TryDispatchUse( vendorGuid, ownedByPlayer: false, useable: true, reservation2, transport, out _); // If this fails, RuntimeInteractionTransactionState/VendorState is // the suppressor. If it passes (expected, given VendorState.Close() // touches no interaction-transaction state and ActiveVendorId only // gates USING SHOP ITEMS, not the vendor NPC itself -- see // ItemInteractionPolicy.DecideUse's ContainerId check), the // suppression is NOT here. Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result2); Assert.Equal(new[] { vendorGuid, vendorGuid }, transport.Uses); Assert.Equal(1, transactions.Inventory.BusyCount); // --- Server re-approaches (mirrors Vendor.ActOnUse's UNCONDITIONAL // ApproachVendor -- confirmed no server-side "already open" gate // exists; see the G4 evidence chain in the final report). Our own // VendorState.Apply must reopen from a previous==0 baseline (Close() // already zeroed it), which VendorUiController.OnVendorChanged's // Opened case turns into _window.Show(). var kinds = new List(); vendor.Changed += t => kinds.Add(t.Kind); Dispatch(BuildApproachVendorPayload( vendorGuid: vendorGuid, categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false, buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u, currencyName: "", items: [])); Assert.Equal(vendorGuid, vendor.VendorId); Assert.Equal([VendorStateTransitionKind.Opened], kinds); transactions.CompleteUse(0u); Assert.Equal(0, transactions.Inventory.BusyCount); } private sealed class FakeTransport : IRuntimeInteractionTransport { private uint _sequence; public bool IsInWorld { get; set; } = true; public List Uses { get; } = []; public bool TrySendUse(uint serverGuid, out uint sequence) { if (!IsInWorld) { sequence = 0u; return false; } sequence = ++_sequence; Uses.Add(serverGuid); return true; } public bool TrySendPickup( uint itemGuid, uint destinationContainerId, int placement, out uint sequence) { sequence = 0u; return false; } } [Fact] public void VendorId_IsTheLiveActiveVendorIdSeamSource() { // The App-layer ItemInteractionController wires // `activeVendorId: () => runtime.InventoryOwner.Vendor.VendorId` as // a LIVE delegate at its one construction site // (InteractionRetainedUiComposition.CreateItemInteraction) — so this // property IS that seam's data source. Verified here at the Runtime // layer since ItemInteractionController itself lives in // AcDream.App, which AcDream.Runtime.Tests cannot reference. using GameRuntime runtime = Create(); VendorState vendor = runtime.InventoryOwner.Vendor; Assert.Equal(0u, vendor.VendorId); Assert.True(vendor.Apply( 0x40001000u, default, Array.Empty())); Assert.Equal(0x40001000u, vendor.VendorId); Assert.True(vendor.Close()); Assert.Equal(0u, vendor.VendorId); } // ---- fixtures / helpers ------------------------------------------ private readonly record struct VendorItemFixture( uint ItemGuid, int StackSize, string Name, uint WeenieClassId, uint RawIconId, uint ItemType, int? Value); private IDisposable Wire(VendorState vendor) => GameEventWiring.WireAll( _dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), vendor: vendor); // One dispatcher per test instance (xUnit constructs a fresh instance // per test method, so this is not shared across tests). private readonly GameEventDispatcher _dispatcher = new(); private void Dispatch(byte[] payload) { GameEventEnvelope? envelope = GameEventEnvelope.TryParse( WrapEnvelope(GameEventType.ApproachVendor, payload)); Assert.NotNull(envelope); _dispatcher.Dispatch(envelope.Value); } private static byte[] WrapEnvelope(GameEventType type, byte[] payload) { byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)type); Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length); return body; } private static byte[] BuildApproachVendorPayload( uint vendorGuid, uint categories, uint minValue, uint maxValue, bool dealsMagic, float buyPrice, float sellPrice, uint currencyWcid, uint currencyAmount, string currencyName, IReadOnlyList items) { var b = new List(); WireU32(b, vendorGuid); WireU32(b, categories); WireU32(b, minValue); WireU32(b, maxValue); WireU32(b, dealsMagic ? 1u : 0u); WireF32(b, buyPrice); WireF32(b, sellPrice); WireU32(b, currencyWcid); WireU32(b, currencyAmount); WireStr16L(b, currencyName); WireU32(b, (uint)items.Count); foreach (VendorItemFixture item in items) { uint packed = ((uint)item.StackSize & 0xFFFFFFu) | 0xFF000000u; WireU32(b, packed); WireU32(b, item.ItemGuid); // Fixed PWD prefix (PublicWeenieDescParser.Parse). Only weenieFlags // bit 0x8 (Value) is set here — the single optional-tail field // these fixtures need. uint weenieFlags = item.Value.HasValue ? 0x00000008u : 0u; WireU32(b, weenieFlags); WireStr16L(b, item.Name); WirePackedDword(b, item.WeenieClassId); WirePackedDword(b, item.RawIconId); WireU32(b, item.ItemType); WireU32(b, 0u); // objectDescriptionFlags WireAlign(b); if (item.Value.HasValue) WireU32(b, unchecked((uint)item.Value.Value)); // Per-item trailing align — VendorApproach.TryParse's own // AlignTo4 call after the shared parser returns. WireAlign(b); } return b.ToArray(); } private static void WireU32(List b, uint v) { Span t = stackalloc byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(t, v); b.AddRange(t.ToArray()); } private static void WireF32(List b, float v) => WireU32(b, unchecked((uint)BitConverter.SingleToInt32Bits(v))); private static void WireU16(List b, ushort v) { Span t = stackalloc byte[2]; BinaryPrimitives.WriteUInt16LittleEndian(t, v); b.AddRange(t.ToArray()); } /// ACE Extensions.cs:12-21 — u16 length, CP1252 bytes, pad to /// a multiple of 4 including the 2 length bytes. private static void WireStr16L(List b, string s) { byte[] bytes = Encoding.GetEncoding(1252).GetBytes(s); WireU16(b, (ushort)bytes.Length); b.AddRange(bytes); int total = 2 + bytes.Length; int pad = (4 - (total & 3)) & 3; for (int i = 0; i < pad; i++) b.Add(0); } /// ACE Extensions.cs:23-34 — values <= 32767 as a plain u16; /// larger values as the extended two-u16 form. private static void WirePackedDword(List b, uint v) { if (v <= 32767) { WireU16(b, (ushort)v); return; } uint packed = (v << 16) | ((v >> 16) | 0x8000); WireU32(b, packed); } private static void WireAlign(List b) { int pad = (4 - (b.Count & 3)) & 3; for (int i = 0; i < pad; i++) b.Add(0); } private static GameRuntime Create() { var operations = new Operations(); return new GameRuntime(new GameRuntimeDependencies( operations, operations, operations, operations)); } private sealed class NoOpResetHost : IRuntimeGenerationResetHost { public void RetireEntityProjection(RuntimeEntityRecord entity) { } public void DrainEntityProjectionBoundary() { } public void CompleteEntityProjectionRetirement() { } } private sealed class Operations : IRuntimeCombatAttackOperations, IRuntimeCombatTargetOperations, IRuntimeCombatModeOperations, IRuntimeSpellCastOperations { public bool CanStartAttack() => false; public void PrepareAttackRequest() { } public bool SendAttack(AttackHeight height, float power) => false; public void SendCancelAttack() { } public bool IsDualWield => false; public bool PlayerReadyForAttack => false; public bool AutoRepeatAttack => false; public bool AutoTarget => false; public uint? SelectClosestTarget() => null; public bool IsInWorld => false; public IReadOnlyList GetOrderedEquipment() => []; public void NotifyExplicitCombatModeRequest() { } public void SendChangeCombatMode(CombatMode mode) { } public uint LocalPlayerId => 0u; public bool CanSend => false; public bool HasRequiredComponents(uint spellId) => false; public bool IsTargetCompatible( uint targetId, SpellMetadata spell, bool showMessage) => false; public void StopCompletely() { } public void SendUntargeted(uint spellId) { } public void SendTargeted(uint targetId, uint spellId) { } public void DisplayMessage(string message) { } public void IncrementBusy() { } } }