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
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:
parent
70f37dbd5c
commit
9796d71522
12 changed files with 835 additions and 9 deletions
|
|
@ -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);
|
||||
|
|
|
|||
110
src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs
Normal file
110
src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs
Normal 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) >
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue