fix(runtime/core): Slice 5.3 review corrections — retirement/transit close, per-unit pricing, guarded auto-close dispatch
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 adversarial review's three blocking findings, each fixed at root:

1. A vendor session now CLOSES when its entity retires (despawn,
   death, ObjectDelete) and at teleport BEGIN
   (HasPendingTeleportStart || IsTeleportActive at the existing
   per-frame seam — both hosts funnel through
   RuntimeWorldTransitState.TryQueueTeleportStart, which flips the
   pending flag strictly before activation). The previous permissive
   early-return stranded the session forever: panel pinned to a stale
   guid, ActiveVendorId swallowing Use for the rest of the session.
2. VendorShopItem carries the desc's stack size, and
   VendorPricing.PerUnitValue ports retail's stack-total division
   (VendorProfile::VendorSellPrice 0x005D1B00: <= 0 guard, integer
   division) — a stack of 50 arrows now prices per arrow, not at 50x.
3. VendorState.Close() guards its observer fanout with the
   dispatcher's catch-and-log semantics — a throwing panel listener
   can no longer propagate into the unprotected per-frame path.

Register honesty rides along: the 0.6 m UseRadius fallback was
acdream's invention (ACE's CheckClose has no fallback; retail passes
the raw authored radius) — removed, the watcher now uses the raw
radius and AP-160's citations are corrected and extended with the
accepted-position-snapshot cadence; AD-72 files VendorPricing's
double-vs-x87-extended narrowing (AD-33's class, bounded by the
±0.1 margin).

Nine tests added. Clean-room complete solution: 11,311 passed /
4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 16:15:57 +02:00
parent 9796d71522
commit 609a2dfda0
9 changed files with 369 additions and 23 deletions

View file

@ -81,26 +81,136 @@ public sealed class RuntimeVendorRangeQueryTests
}
[Fact]
public void EnforceRange_VendorUseRadiusAbsent_FallsBackToTheAceDefault()
public void EnforceRange_VendorUseRadiusAbsent_UsesRawZeroWithNoFallback()
{
// ACE WorldObject_Use.cs:50 — `wo.UseRadius ?? 0.6f`.
// Slice 5.3 review fix 4: retail passes the raw authored UseRadius
// with NO client-side fallback (ACE's 0.6f lives in the APPROACH
// check, WorldObject_Use.cs:50/57 — never in the close watcher,
// Vendor.CheckClose, which never closes at all on a null radius).
// acdream's own watcher must actively close, so an absent radius
// maps to the raw retail default of 0 (PublicWeenieDesc's _useRadius
// is memset, not sentineled) — any nonzero distance is then
// out-of-range.
using GameRuntime runtime = Create();
runtime.PlayerIdentity.ServerGuid = Player;
RuntimeEntityRecord playerRecord =
Add(runtime, Player, Landblock, 100f, 100f);
Add(runtime, Vendor, Landblock, 100.5f, 100f, useRadius: null);
Add(runtime, Vendor, Landblock, 100f, 100f, useRadius: null);
Open(runtime, Vendor);
// 0.5 m: inside the 0.6 m fallback.
// Exact same position: distance 0 <= radius 0 — still open.
RuntimeVendorRangeQuery.EnforceRange(runtime);
Assert.Equal(Vendor, runtime.InventoryOwner.Vendor.VendorId);
// Walk to 2 m: outside the 0.6 m fallback.
SetPosition(playerRecord, Landblock, 102.5f, 100f);
// Any nonzero move at all — even 5 cm — is out of range at radius 0.
SetPosition(playerRecord, Landblock, 100.05f, 100f);
RuntimeVendorRangeQuery.EnforceRange(runtime);
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
}
[Fact]
public void EnforceRange_VendorEntityRetired_ClosesTheSession()
{
// Fix 1a: retail's own range watcher dies with its target. The
// permissive early-return this used to take on a failed
// TryGetActive stranded the session open forever once the vendor
// NPC despawned/died/was ObjectDeleted (e.g. during a recall).
using GameRuntime runtime = Create();
runtime.PlayerIdentity.ServerGuid = Player;
Add(runtime, Player, Landblock, 100f, 100f);
RuntimeEntityRecord vendorRecord =
Add(runtime, Vendor, Landblock, 102f, 100f, useRadius: 3f);
Open(runtime, Vendor);
// Directly removes the vendor from the active set — the same
// terminal state a despawn/death/ObjectDelete leaves behind
// (RuntimeEntityDirectory.RemoveActive is what TryAcceptDelete
// itself calls once a delete's InstanceSequence matches).
Assert.True(runtime.EntityObjects.Entities.RemoveActive(vendorRecord));
RuntimeVendorRangeQuery.EnforceRange(runtime);
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
}
[Fact]
public void EnforceRange_TeleportQueued_ClosesTheSessionBeforeArrival()
{
// Fix 1b: an in-session portal/teleport must close at transit
// BEGIN (TryQueueTeleportStart succeeding, i.e.
// HasPendingTeleportStart), not wait for the arrival frame
// (ActivateQueuedTeleport / IsTeleportActive). The player and
// vendor stay well within range the whole time — only the queued
// transit forces the close.
using GameRuntime runtime = Create();
runtime.PlayerIdentity.ServerGuid = Player;
Add(runtime, Player, Landblock, 100f, 100f);
Add(runtime, Vendor, Landblock, 100f, 100f, useRadius: 3f);
Open(runtime, Vendor);
Assert.True(runtime.TransitOwner.TryQueueTeleportStart(1));
Assert.True(runtime.TransitOwner.HasPendingTeleportStart);
Assert.False(runtime.TransitOwner.IsTeleportActive);
RuntimeVendorRangeQuery.EnforceRange(runtime);
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
}
[Fact]
public void EnforceRange_TeleportActive_ClosesTheSession()
{
// The other half of the transit window: once the queued teleport
// has been promoted to active (ActivateQueuedTeleport), the vendor
// session stays closed rather than being able to reopen mid-flight.
using GameRuntime runtime = Create();
runtime.PlayerIdentity.ServerGuid = Player;
Add(runtime, Player, Landblock, 100f, 100f);
Add(runtime, Vendor, Landblock, 100f, 100f, useRadius: 3f);
Open(runtime, Vendor);
Assert.True(runtime.TransitOwner.TryQueueTeleportStart(1));
Assert.True(runtime.TransitOwner.ActivateQueuedTeleport());
Assert.True(runtime.TransitOwner.IsTeleportActive);
RuntimeVendorRangeQuery.EnforceRange(runtime);
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
}
[Fact]
public void EnforceRange_ThrowingChangedObserverDuringAutoClose_DoesNotPropagate()
{
// Fix 3: VendorState.Close()'s sole production caller is this
// per-frame query, with no try/catch anywhere up the frame-loop
// chain. A throwing presentation observer must not kill the frame,
// and the session must still end up closed.
using GameRuntime runtime = Create();
runtime.PlayerIdentity.ServerGuid = Player;
RuntimeEntityRecord playerRecord =
Add(runtime, Player, Landblock, 100f, 100f);
Add(runtime, Vendor, Landblock, 102f, 100f, useRadius: 3f);
Open(runtime, Vendor);
Action<VendorTransition> throwingObserver =
_ => throw new InvalidOperationException("boom");
runtime.InventoryOwner.Vendor.Changed += throwingObserver;
SetPosition(playerRecord, Landblock, 122f, 100f);
var exception = Record.Exception(
() => RuntimeVendorRangeQuery.EnforceRange(runtime));
Assert.Null(exception);
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
// Detach before this scope's `using` disposal reaches
// RuntimeInventoryState.Dispose -> Vendor.Reset(), which — unlike
// Close() — legitimately rethrows (see Close()'s doc comment for
// why the two diverge). Leaving this attached would fail at
// teardown, not at the EnforceRange call this test exercises.
runtime.InventoryOwner.Vendor.Changed -= throwingObserver;
}
private static void Open(GameRuntime runtime, uint vendorGuid) =>
Assert.True(runtime.InventoryOwner.Vendor.Apply(
vendorGuid,