feat(runtime): Slice 5.3 — RuntimeInventoryState owns the vendor browse session
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

The sole VendorState joins the J4.2 inventory owners: populated by the
new 0x0062 ApproachVendor route (parse via VendorApproach, wire-to-
domain mapping at the routing seam, silent-drop on malformed like every
sibling), borrowed by both graphical and headless hosts, and torn down
through the EXISTING ExternalContainer reset stage — session reset,
portal-out, and logout all funnel through the one mechanism. Close is
client-local per retail (nothing on the wire): a range watcher rides
the existing per-advanced-frame publishMovement callback, using the
vendor's own authored UseRadius (ACE's 0.6 m fallback when absent).
The dormant ItemInteractionController ActiveVendorId seam is finally
wired as a live delegate — real id while open, 0 the moment the
session clears.

AP-160 filed in this same commit: the watcher measures plain 3D center
distance rather than retail's cylinder-gap, because Runtime has no
per-NPC collision radius/height source; bounded sub-meter, client-
local UI only.

Twelve Runtime tests: populate/field mapping, vendor replacement,
range clear + within-range retention, all three generation teardowns,
the ActiveVendorId seam, malformed-event drop. Clean-room complete
solution: 11,302 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 15:37:12 +02:00
parent 70f37dbd5c
commit 9796d71522
12 changed files with 835 additions and 9 deletions

View file

@ -83,6 +83,10 @@ public static class GameEventWiring
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
Func<double>? clientTime = null,
ExternalContainerState? externalContainers = null,
// Slice 5.3: the vendor browse session owner. Matches the existing
// itemMana/friends/squelch/externalContainers pattern — optional so
// every existing caller compiles unchanged.
VendorState? vendor = null,
Func<bool>? accepting = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
@ -375,6 +379,51 @@ public static class GameEventWiring
items.UpdateHouseRestrictions(p.Value.SenderId, p.Value.Restrictions);
});
// Slice 5.3: ApproachVendor (0x0062) — the sole wire message that
// opens a vendor's shop; it rides the ordinary Use action, there is
// no separate "open vendor" opcode (research doc
// docs/research/2026-08-08-slice5-vendor-browse-research.md §A.1-A.2).
// Every event is a COMPLETE REPLACE (§A.3) — VendorState.Apply is a
// single-phase authoritative-replace call, matching that contract.
// A malformed payload is dropped silently: every sibling handler in
// this section (WieldObject, InventoryPutObjInContainer,
// HouseUpdateRestrictions above, ViewContents/CloseGroundContainer
// below) uses the same `if (p is null) return;` shape with no
// logging — there is no established parse-failure logging
// convention in this file to deviate from.
registrar.Register(GameEventType.ApproachVendor, e =>
{
var p = VendorApproach.TryParse(e.Payload.Span);
if (p is null) return;
var profile = new VendorShopProfile(
p.Value.Profile.MerchandiseItemTypes,
p.Value.Profile.MerchandiseMinValue,
p.Value.Profile.MerchandiseMaxValue,
p.Value.Profile.DealMagicalItems,
p.Value.Profile.BuyPrice,
p.Value.Profile.SellPrice,
p.Value.Profile.AlternateCurrencyWcid,
p.Value.Profile.AlternateCurrencyAmount,
p.Value.Profile.AlternateCurrencyPluralName);
var shopItems = new VendorShopItem[p.Value.Items.Count];
for (int i = 0; i < shopItems.Length; i++)
{
VendorApproach.ItemProfile item = p.Value.Items[i];
shopItems[i] = new VendorShopItem(
item.ItemGuid,
item.StackSize,
item.Desc.WeenieClassId,
item.Desc.Name,
item.Desc.ItemType,
item.Desc.IconId,
item.Desc.Value);
}
vendor?.Apply(p.Value.VendorGuid, profile, shopItems);
});
// ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you
// opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without
// inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so