acdream/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs
Erik 97cf873870
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
feat(vendor): Slice 6 buy arc — shop items are real objects, vendor selection is THE selection, and Buy works (0x005F)
Three ordered pieces in one landing (the shared controller/composition
files carry all three; the internal order was 6.1 -> 6.2 -> 6.3):

6.1 VendorShopItemMaterializer diff-merges the shop list into the live
ClientObjectTable on VendorState transitions (so client-local close and
session teardown retire the entries too) and never claims a guid it did
not add — ACE's UniqueItemsForSale can re-list a guid a player once
held (AP-163 files the collision-skip; no retail counterpart traced).
Right-click examine on shop items now routes through the ordinary
appraisal path — the 5.4 F7c blocker dissolves with the table entries.

6.2 SelectionChangeSource.Vendor: row clicks, auto-select, and examine
all flow through the canonical SelectionState; the status bar and the
existing byte-faithful StackSplitQuantityState slider light up
unmodified. VendorSplitPolicy is the single 0xDC41CB0 mask owner; the
slider VALUE seeds to 1 for exempt items while maxSplitSize keeps the
stack (the splitSize/maxSplitSize distinction, research §B.3).
Selection clears at retail's actual site — VendorItemsUI::RemoveFromShop
(pc:202848), not a CloseVendor-level clear that does not exist.

6.3 BuildBuy (0x005F): vendorGuid, count, (i32 amount, u32 guid) pairs,
and the trailing alternateCurrencyId the REAL client sends
(CM_Vendor::Event_Buy pc:689288) though ACE's reader ignores it.
TryBuy rides the EXISTING J5.2 one-request-at-a-time reservation and
completes on UseDone; the Buy button disables while a request is in
flight. The reconciliation round-trip (money property update, inventory
CreateObject, ApproachVendor refresh -> panel rebuild) is proven by a
synthetic-inbound test against existing machinery — no new owner.

Register: AP-161 narrowed (selection + examine residuals close;
staging/Sell remain; double-click-to-buy confirmed ABSENT from retail
with negative evidence cited — we match retail). AP-162 files the
conscious no-client-side-affordability-precheck deferral.

Clean-room complete solution: 11,368 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 20:28:26 +02:00

463 lines
18 KiB
C#

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;
/// <summary>
/// Slice 5.3 — Runtime ownership of the vendor browse session:
/// <see cref="RuntimeInventoryState.Vendor"/> is populated by the inbound
/// <c>ApproachVendor</c> (0x0062) route (<c>GameEventWiring.WireAll</c>'s
/// <c>vendor</c> parameter) and torn down by the shared generation-reset
/// mechanism (session reset / portal-out / logout).
/// </summary>
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<VendorStateTransitionKind>();
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<VendorShopItem>()));
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<VendorShopItem>()));
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 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<VendorShopItem>()));
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<VendorItemFixture> items)
{
var b = new List<byte>();
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<byte> b, uint v)
{
Span<byte> t = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(t, v);
b.AddRange(t.ToArray());
}
private static void WireF32(List<byte> b, float v) =>
WireU32(b, unchecked((uint)BitConverter.SingleToInt32Bits(v)));
private static void WireU16(List<byte> b, ushort v)
{
Span<byte> t = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(t, v);
b.AddRange(t.ToArray());
}
/// <summary>ACE Extensions.cs:12-21 — u16 length, CP1252 bytes, pad to
/// a multiple of 4 including the 2 length bytes.</summary>
private static void WireStr16L(List<byte> 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);
}
/// <summary>ACE Extensions.cs:23-34 — values &lt;= 32767 as a plain u16;
/// larger values as the extended two-u16 form.</summary>
private static void WirePackedDword(List<byte> 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<byte> 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<ClientObject> 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() { }
}
}