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

@ -332,6 +332,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.PlayerIdentity.ServerGuid),
groundObjectId: () =>
d.Inventory.ExternalContainers.CurrentContainerId,
// Slice 5.3: finally wires the dormant vendor-id seam
// (ItemInteractionPolicy.ActiveVendorId's "using an item inside
// the currently-open vendor's shop is swallowed as a no-op"
// branch, research doc §C.1). A live delegate rather than a
// captured value, so it reads 0 automatically once VendorState
// closes — no separate "clear on close" wiring needed.
activeVendorId: () => d.Inventory.Vendor.VendorId,
sendSplitToWorld: (item, amount) =>
session.CurrentSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () =>

View file

@ -286,7 +286,8 @@ internal sealed class LiveSessionRuntimeFactory
retailUi.HandleAppraisal(appraisal);
else
_interaction.ItemInteraction.AcceptAppraisalResponse(appraisal.Guid);
});
},
Vendor: _domain.Inventory.Vendor);
private LiveCharacterSessionBindings CreateCharacterBindings(
SkillTable? skillTable)

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

View file

@ -711,7 +711,8 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime.InventoryOwner.ExternalContainers,
appraisal =>
Runtime.ActionOwner.Transactions
.AcceptAppraisalResponse(appraisal.Guid)),
.AcceptAppraisalResponse(appraisal.Guid),
Vendor: Runtime.InventoryOwner.Vendor),
new LiveCharacterSessionBindings(
Runtime.ActionOwner.Combat,
Runtime.CharacterOwner,

View file

@ -409,7 +409,16 @@ public sealed class GameRuntime
return new RuntimeLocalPlayerFrameController(
host,
input,
() => _events.EmitMovement(MovementOwner.Snapshot));
() =>
{
_events.EmitMovement(MovementOwner.Snapshot);
// Slice 5.3: the local-player movement publish already fires
// once per advanced frame for both graphical and no-window
// hosts (RuntimeLocalPlayerFrameController.RunPostNetworkCommandPhase),
// so the client-local vendor distance watcher piggybacks on
// it instead of adding a second polling loop.
RuntimeVendorRangeQuery.EnforceRange(this);
});
}
public void ResetGeneration(

View file

@ -15,7 +15,9 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
int ShortcutCount,
int ShortcutSubscriberCount,
long ShortcutDispatchFailureCount,
long TransactionDispatchFailureCount)
long TransactionDispatchFailureCount,
// Slice 5.3: the sole open vendor shop id, 0 when no session is open.
uint VendorId)
{
public bool IsConverged =>
IsDisposed
@ -27,7 +29,8 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
&& CurrentContainerId == 0u
&& ItemManaCount == 0
&& ShortcutCount == 0
&& ShortcutSubscriberCount == 0;
&& ShortcutSubscriberCount == 0
&& VendorId == 0u;
}
/// <summary>
@ -48,6 +51,13 @@ public sealed class RuntimeInventoryState : IDisposable
ItemMana = new ItemManaState();
Shortcuts = new ShortcutStore();
Transactions = new InventoryTransactionState(_entityObjects.Objects);
// Slice 5.3: VendorState joins RuntimeInventoryState exactly the way
// ExternalContainerState does (research doc
// docs/research/2026-08-08-slice5-vendor-browse-research.md §C.2) —
// same "authoritative server-driven full-replace view... with a
// Changed event for presentation observers" shape, generation-gated
// and torn down alongside the rest of this owner's children.
Vendor = new VendorState();
View = new InventoryStateView(this);
}
@ -56,6 +66,7 @@ public sealed class RuntimeInventoryState : IDisposable
public ItemManaState ItemMana { get; }
public ShortcutStore Shortcuts { get; }
public InventoryTransactionState Transactions { get; }
public VendorState Vendor { get; }
public IRuntimeInventoryStateView View { get; }
public bool IsDisposed => _disposed;
@ -71,11 +82,13 @@ public sealed class RuntimeInventoryState : IDisposable
Shortcuts.Count,
Shortcuts.SubscriberCount,
Shortcuts.DispatchFailureCount,
Transactions.DispatchFailureCount);
Transactions.DispatchFailureCount,
Vendor.VendorId);
public void ResetExternalContainer() => ExternalContainers.Reset();
public void ResetTransactions() => Transactions.ResetSession();
public void ResetItemMana() => ItemMana.Clear();
public void ResetVendor() => Vendor.Reset();
public void ResetPlayerSnapshots()
{
@ -142,6 +155,7 @@ public sealed class RuntimeInventoryState : IDisposable
try
{
Try(() => ExternalContainers.Reset(), ref failures);
Try(() => Vendor.Reset(), ref failures);
Try(ItemMana.Clear, ref failures);
Try(Shortcuts.Dispose, ref failures);
Try(Transactions.Dispose, ref failures);

View file

@ -0,0 +1,110 @@
using System.Numerics;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Client-local vendor-panel distance watcher (Slice 5.3, research doc
/// <c>docs/research/2026-08-08-slice5-vendor-browse-research.md</c> §A.3/§B.1/
/// §B.2). Retail's <c>gmVendorUI::OpenVendor</c> (pc:203650, 0x004C4BA0)
/// registers a range handler via <c>CPlayerSystem::RegisterObjectRangeHandler</c>
/// (pc:203677, 0x004C4C34) keyed to the vendor's OWN
/// <c>PublicWeenieDesc._useRadius</c>; <c>gmVendorUI::OnObjectRangeExit</c>
/// (pc:199486, 0x004C02F0) then calls <c>gmVendorUI::CloseVendor</c>
/// (pc:202080, 0x004C3020) — a pure client-side teardown, no wire message.
/// ACE's server-side belt-and-suspenders equivalent, <c>Vendor.CheckClose</c>
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367</c>),
/// polls every 1.5 s and closes when <c>GetCylinderDistance(lastPlayer) &gt;
/// UseRadius</c>, falling back to <c>wo.UseRadius ?? 0.6f</c> when the vendor
/// carries no explicit radius (<c>WorldObject_Use.cs:50,57</c>).
/// </summary>
public static class RuntimeVendorRangeQuery
{
/// <summary>ACE <c>WorldObject_Use.cs:50</c>: <c>wo.UseRadius ?? 0.6f</c>.</summary>
private const float DefaultUseRadius = 0.6f;
/// <summary>
/// Close the open vendor session (if any) once the local player has
/// moved beyond the vendor's own UseRadius. No-op when no vendor is
/// open, or when either side's live position cannot be resolved this
/// tick (matches the existing App-layer convention at
/// <c>WorldSelectionQuery.IsWithinExternalContainerUseRange</c>: "the
/// server remains authoritative while render projection is absent" —
/// never force-close on missing data).
///
/// <para>
/// <b>Distance metric divergence (register AP-160):</b> retail/ACE close
/// on CYLINDER-GAP distance — both objects' own collision radius and
/// height subtracted from the center distance
/// (<c>Position::cylinder_distance</c>/ACE's <c>GetCylinderDistance</c>).
/// Runtime does not resolve a live per-NPC collision radius/height
/// outside the App-layer's Setup-cylinder resolver
/// (<c>WorldSelectionQuery</c>, App-only — out of reach per the
/// Core-structure rules), so this uses plain 3D center-to-center
/// distance via <see cref="ObjectRangeMath.ObjectsInRange"/>'s
/// <c>useRadii: false</c> branch instead. Effect: the panel can close up
/// to (player radius + vendor radius) sooner than exact retail —
/// typically well under a meter for a two-legged NPC.
/// </para>
/// </summary>
public static void EnforceRange(GameRuntime runtime)
{
ArgumentNullException.ThrowIfNull(runtime);
VendorState vendor = runtime.InventoryOwner.Vendor;
uint vendorId = vendor.VendorId;
if (vendorId == 0u)
return;
uint playerGuid = runtime.PlayerIdentity.ServerGuid;
if (playerGuid == 0u
|| !runtime.EntityObjects.Entities.TryGetActive(
playerGuid,
out RuntimeEntityRecord playerRecord)
|| playerRecord.Snapshot.Position is not { } playerPosition)
{
return;
}
if (!runtime.EntityObjects.Entities.TryGetActive(
vendorId,
out RuntimeEntityRecord vendorRecord)
|| vendorRecord.Snapshot.Position is not { } vendorPosition)
{
return;
}
float useRadius = vendorRecord.Snapshot.UseRadius ?? DefaultUseRadius;
bool inRange = ObjectRangeMath.ObjectsInRange(
AbsolutePosition(playerPosition),
0f,
0f,
AbsolutePosition(vendorPosition),
0f,
0f,
useRadius,
useRadii: false,
ignoreZDelta: false);
if (!inRange)
vendor.Close();
}
/// <summary>
/// Same reconstruction <see cref="RuntimeHostileTargetQuery"/> uses:
/// wire local XYZ plus the landblock-prefix world offset (each
/// landblock is 192 m).
/// </summary>
private static Vector3 AbsolutePosition(CreateObject.ServerPosition position)
{
int landblockX = (int)((position.LandblockId >> 24) & 0xFFu);
int landblockY = (int)((position.LandblockId >> 16) & 0xFFu);
return new Vector3(
position.PositionX + landblockX * 192f,
position.PositionY + landblockY * 192f,
position.PositionZ);
}
}

View file

@ -246,7 +246,18 @@ public sealed class RuntimeGenerationReset
Advance(state, _communication.ResetCommandTargets);
break;
case RuntimeGenerationResetStage.ExternalContainer:
Advance(state, _inventory.ResetExternalContainer);
// Slice 5.3: the vendor browse session shares the
// external-container stage rather than claiming a new
// enum ordinal — both are client-local "open server
// object" sessions torn down uniformly at session
// reset/portal-out/logout (research doc §C.2's
// "generation/lifecycle contract every other J4/J5 child
// follows").
Advance(state, () =>
{
_inventory.ResetExternalContainer();
_inventory.ResetVendor();
});
break;
case RuntimeGenerationResetStage.Actions:
Advance(state, _actions.ResetSession);

View file

@ -37,7 +37,10 @@ public sealed record LiveInventorySessionBindings(
Action<uint>? OnUseDone,
ItemManaState? ItemMana,
ExternalContainerState? ExternalContainers,
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null);
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null,
// Slice 5.3: the vendor browse session owner. Trailing/optional so every
// existing positional caller (Headless) compiles unchanged.
VendorState? Vendor = null);
public sealed record LiveCharacterSessionBindings(
CombatState Combat,
@ -186,6 +189,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
onCharacterOptions: character.Character.Options.Replace,
clientTime: character.ClientTime,
externalContainers: inventory.ExternalContainers,
vendor: inventory.Vendor,
accepting: IsAccepting));
ConstructionCheckpoint();