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
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:
parent
9796d71522
commit
609a2dfda0
9 changed files with 369 additions and 23 deletions
File diff suppressed because one or more lines are too long
|
|
@ -418,7 +418,13 @@ public static class GameEventWiring
|
||||||
item.Desc.Name,
|
item.Desc.Name,
|
||||||
item.Desc.ItemType,
|
item.Desc.ItemType,
|
||||||
item.Desc.IconId,
|
item.Desc.IconId,
|
||||||
item.Desc.Value);
|
item.Desc.Value,
|
||||||
|
// Slice 5.3 review fix 2: the DESC's own StackSize (NOT
|
||||||
|
// item.StackSize above, ItemProfile's separate packed
|
||||||
|
// supply-count field) -- VendorPricing.PerUnitValue's
|
||||||
|
// divisor for turning Value's stack-total wire number
|
||||||
|
// into a per-unit display price.
|
||||||
|
item.Desc.StackSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
vendor?.Apply(p.Value.VendorGuid, profile, shopItems);
|
vendor?.Apply(p.Value.VendorGuid, profile, shopItems);
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,45 @@ namespace AcDream.Core.Items;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class VendorPricing
|
public static class VendorPricing
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <c>VendorProfile::VendorSellPrice</c>/<c>VendorBuyPrice</c>'s shared
|
||||||
|
/// per-unit division (<c>0x005D1B00</c>/<c>0x005D1B70</c>, both bodies
|
||||||
|
/// identical apart from which rate/branch they go on to feed
|
||||||
|
/// <see cref="SellPrice"/>/<see cref="BuyPrice"/> — read directly from
|
||||||
|
/// the decompiled body, <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:484801-484813</c>).
|
||||||
|
/// The wire item's <c>Value</c> field is the STACK's TOTAL value, not a
|
||||||
|
/// per-unit price — retail divides it by the item's own authored
|
||||||
|
/// <c>PublicWeenieDesc::_stackSize</c> (<paramref name="descStackSize"/>
|
||||||
|
/// — NOT <see cref="VendorShopItem.StackSize"/>, which is
|
||||||
|
/// <c>ItemProfile</c>'s separately-packed SUPPLY count, a different wire
|
||||||
|
/// field entirely) before either price formula ever sees it. Slice 5.3
|
||||||
|
/// review fix 2.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stackTotalValue">
|
||||||
|
/// The item's raw wire <c>Value</c> (retail <c>_value</c> — the whole
|
||||||
|
/// stack's total value, not one unit's).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="descStackSize">
|
||||||
|
/// The item's own <c>PublicWeenieDesc::_stackSize</c> (retail
|
||||||
|
/// <c>_stackSize</c>). <see langword="null"/> (the field absent on the
|
||||||
|
/// wire) is treated exactly like retail's zeroed-struct default when the
|
||||||
|
/// field was never sent — same as an explicit 0 or negative: no
|
||||||
|
/// division.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// <paramref name="stackTotalValue"/> unchanged when
|
||||||
|
/// <paramref name="descStackSize"/> is absent or <c><= 0</c> (retail
|
||||||
|
/// <c>pc:484808-484810</c>: <c>if (_stackSize <= 0) return
|
||||||
|
/// SellPrice(_value, ...)</c>); otherwise the INTEGER-divided per-unit
|
||||||
|
/// value (retail <c>pc:484812</c>: <c>COMBINE(0, _value) / _stackSize</c>
|
||||||
|
/// — a plain non-negative integer divide, .NET's <c>int</c> division
|
||||||
|
/// truncates toward zero the same way).
|
||||||
|
/// </returns>
|
||||||
|
public static int PerUnitValue(int stackTotalValue, int? descStackSize) =>
|
||||||
|
descStackSize is { } size && size > 0
|
||||||
|
? stackTotalValue / size
|
||||||
|
: stackTotalValue;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>ShopSystem::BuyPrice</c> (<c>0x006B6120</c>): the price the vendor
|
/// <c>ShopSystem::BuyPrice</c> (<c>0x006B6120</c>): the price the vendor
|
||||||
/// PAYS the player for <paramref name="quantity"/> units of an item
|
/// PAYS the player for <paramref name="quantity"/> units of an item
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,20 @@ public readonly record struct VendorShopItem(
|
||||||
string? Name,
|
string? Name,
|
||||||
uint? ItemType,
|
uint? ItemType,
|
||||||
uint IconId,
|
uint IconId,
|
||||||
int? Value);
|
int? Value,
|
||||||
|
// Slice 5.3 review fix 2: the ITEM'S OWN authored stack depth (retail
|
||||||
|
// PublicWeenieDesc::_stackSize, wire AcDream.Core.Net.Messages.
|
||||||
|
// PublicWeenieDescBody.StackSize) -- the divisor VendorPricing.PerUnitValue
|
||||||
|
// needs to turn Value's STACK-TOTAL wire number into a per-unit display
|
||||||
|
// price (VendorProfile::VendorSellPrice/VendorBuyPrice, 0x005D1B00/
|
||||||
|
// 0x005D1B70). This is a DIFFERENT wire field from StackSize above:
|
||||||
|
// that one is ItemProfile's packed SUPPLY count (how many the vendor has
|
||||||
|
// in stock), this one is how many units make up one priced stack (e.g.
|
||||||
|
// 50 for a stack of arrows). Nullable because the wire field is
|
||||||
|
// conditionally present (weenieFlags-gated) -- absent maps to null here,
|
||||||
|
// matching retail's own zeroed-struct default of 0 for the same case
|
||||||
|
// (see VendorPricing.PerUnitValue's <= 0 guard).
|
||||||
|
int? DescStackSize = null);
|
||||||
|
|
||||||
public enum VendorStateTransitionKind
|
public enum VendorStateTransitionKind
|
||||||
{
|
{
|
||||||
|
|
@ -134,6 +147,31 @@ public sealed class VendorState
|
||||||
/// different-vendor open superseding this one; see research doc §A.3).
|
/// different-vendor open superseding this one; see research doc §A.3).
|
||||||
/// Returns <c>false</c> if no vendor was open.
|
/// Returns <c>false</c> if no vendor was open.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Slice 5.3 review fix 3.</b> Unlike <see cref="Reset"/>, a failing
|
||||||
|
/// <see cref="Changed"/> observer here is never rethrown. <c>Close()</c>'s
|
||||||
|
/// production caller (<c>RuntimeVendorRangeQuery.EnforceRange</c>) runs
|
||||||
|
/// inside the per-frame post-network-command-phase callback
|
||||||
|
/// (<c>GameRuntime.CreateLocalPlayerFrameController</c>'s post-network
|
||||||
|
/// phase) with no try/catch anywhere up the frame-loop chain — an
|
||||||
|
/// <see cref="AggregateException"/> propagating out of here, <see cref="Reset"/>'s
|
||||||
|
/// shape, would kill the frame. <see cref="Reset"/> keeps that
|
||||||
|
/// collect-and-rethrow shape because ITS callers (session
|
||||||
|
/// reset/portal-out/logout — a rare, explicit teardown boundary) already
|
||||||
|
/// tolerate/handle it (e.g. <c>RuntimeInventoryState.Dispose</c>'s own
|
||||||
|
/// <c>Try(...)</c> wrapper collects <see cref="Reset"/>'s failures
|
||||||
|
/// alongside every other child's). This still fans out to every listener
|
||||||
|
/// via <c>GetInvocationList()</c> (one broken observer must not starve
|
||||||
|
/// another — same resilience as <see cref="Reset"/>), but LOGS each
|
||||||
|
/// failure instead of collecting it into an exception, matching
|
||||||
|
/// <c>GameEventDispatcher.Dispatch</c>'s own boundary contract
|
||||||
|
/// (<c>src/AcDream.Core.Net/Messages/GameEventDispatcher.cs:95-117</c> —
|
||||||
|
/// catch, <c>Console.Error.WriteLine</c>, never rethrow, "the decode
|
||||||
|
/// thread must survive handler failures"): a per-frame boundary must
|
||||||
|
/// survive its own observers' failures the same way. Not silent
|
||||||
|
/// swallowing — the failure surfaces on <see cref="Console.Error"/>
|
||||||
|
/// exactly the way the dispatcher's do.
|
||||||
|
/// </remarks>
|
||||||
public bool Close()
|
public bool Close()
|
||||||
{
|
{
|
||||||
if (VendorId == 0u) return false;
|
if (VendorId == 0u) return false;
|
||||||
|
|
@ -141,7 +179,20 @@ public sealed class VendorState
|
||||||
uint previous = VendorId;
|
uint previous = VendorId;
|
||||||
ClearFields();
|
ClearFields();
|
||||||
|
|
||||||
Changed?.Invoke(new VendorTransition(VendorStateTransitionKind.Closed, previous, 0u));
|
var transition = new VendorTransition(VendorStateTransitionKind.Closed, previous, 0u);
|
||||||
|
Action<VendorTransition>? listeners = Changed;
|
||||||
|
if (listeners is not null)
|
||||||
|
{
|
||||||
|
foreach (Action<VendorTransition> listener in listeners.GetInvocationList())
|
||||||
|
{
|
||||||
|
try { listener(transition); }
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[VendorState] Close() observer threw: {error.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
using AcDream.Runtime.Entities;
|
using AcDream.Runtime.Entities;
|
||||||
|
using AcDream.Runtime.World;
|
||||||
|
|
||||||
namespace AcDream.Runtime.Gameplay;
|
namespace AcDream.Runtime.Gameplay;
|
||||||
|
|
||||||
|
|
@ -18,22 +19,72 @@ namespace AcDream.Runtime.Gameplay;
|
||||||
/// ACE's server-side belt-and-suspenders equivalent, <c>Vendor.CheckClose</c>
|
/// ACE's server-side belt-and-suspenders equivalent, <c>Vendor.CheckClose</c>
|
||||||
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367</c>),
|
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367</c>),
|
||||||
/// polls every 1.5 s and closes when <c>GetCylinderDistance(lastPlayer) >
|
/// 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
|
/// UseRadius</c> — a plain nullable-float comparison with NO fallback
|
||||||
/// carries no explicit radius (<c>WorldObject_Use.cs:50,57</c>).
|
/// default of its own (<c>UseRadius</c> is <c>float?</c>,
|
||||||
|
/// <c>WorldObject_Properties.cs:1242-1246</c>; a nullable comparison against
|
||||||
|
/// a null right operand is always <c>false</c>, so <c>CheckClose</c> never
|
||||||
|
/// closes when the vendor carries no explicit radius). The <c>?? 0.6f</c>
|
||||||
|
/// fallback this class's earlier revision mis-attributed to
|
||||||
|
/// <c>Vendor.CheckClose</c> actually lives in a DIFFERENT method,
|
||||||
|
/// <c>WorldObject.IsWithinUseRadiusOf</c> (<c>WorldObject_Use.cs:50,57</c>) —
|
||||||
|
/// the APPROACH check ("how close you need to be to open the shop"), never
|
||||||
|
/// the close/distance-watch path. Slice 5.3 review fix 4.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class RuntimeVendorRangeQuery
|
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>
|
/// <summary>
|
||||||
/// Close the open vendor session (if any) once the local player has
|
/// 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
|
/// moved beyond the vendor's own UseRadius, once the vendor entity
|
||||||
/// open, or when either side's live position cannot be resolved this
|
/// itself is no longer resolvable, or once an in-session portal/
|
||||||
/// tick (matches the existing App-layer convention at
|
/// teleport has begun. No-op only when no vendor is open, or when the
|
||||||
|
/// PLAYER's own live position cannot be resolved this tick (matches the
|
||||||
|
/// existing App-layer convention at
|
||||||
/// <c>WorldSelectionQuery.IsWithinExternalContainerUseRange</c>: "the
|
/// <c>WorldSelectionQuery.IsWithinExternalContainerUseRange</c>: "the
|
||||||
/// server remains authoritative while render projection is absent" —
|
/// server remains authoritative while render projection is absent" —
|
||||||
/// never force-close on missing data).
|
/// never force-close on missing PLAYER data). A missing/retired VENDOR
|
||||||
|
/// is a different case — see fix 1a below.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Fix 1b — in-session transit closes at BEGIN, not arrival (Slice
|
||||||
|
/// 5.3 review).</b> <see cref="RuntimeWorldTransitState"/> is the
|
||||||
|
/// canonical Runtime owner of the F751/teleport lifecycle (J6.2/J6.3).
|
||||||
|
/// <c>HasPendingTeleportStart</c> flips true the instant
|
||||||
|
/// <c>TryQueueTeleportStart</c> succeeds — for a GRAPHICAL host this is
|
||||||
|
/// strictly BEFORE <c>ActivateQueuedTeleport</c> (and therefore before
|
||||||
|
/// <c>IsTeleportActive</c>), because
|
||||||
|
/// <c>LocalPlayerTeleportController.TryActivatePendingPresentation</c>
|
||||||
|
/// defers activation until the host can enter portal space; for a
|
||||||
|
/// HEADLESS host (<c>RuntimeLiveEntitySessionController.OnTeleportStarted</c>)
|
||||||
|
/// both flip in the same synchronous call. Checking BOTH flags here
|
||||||
|
/// observes "transit begin" through "transit still resolving" for
|
||||||
|
/// either host without a second call site: both hosts
|
||||||
|
/// (<c>LocalPlayerTeleportController.OnTeleportStarted</c> for
|
||||||
|
/// graphical, <c>RuntimeLiveEntitySessionController.OnTeleportStarted</c>
|
||||||
|
/// for headless) reach <c>TryQueueTeleportStart</c> as their sole entry
|
||||||
|
/// point, and this method already runs once per advanced frame for BOTH
|
||||||
|
/// hosts via the SAME post-network-command-phase callback
|
||||||
|
/// (<see cref="GameRuntime.CreateLocalPlayerFrameController"/>) that
|
||||||
|
/// already exists for the range check below — so this reuses that
|
||||||
|
/// existing per-frame seam instead of adding a new polling loop or event
|
||||||
|
/// channel. Unconditional: while a transit is in flight, distance is not
|
||||||
|
/// even evaluated, matching how a portal/teleport makes the player's and
|
||||||
|
/// vendor's positions momentarily incomparable (different landblock
|
||||||
|
/// frames — see register AP-160's extension).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Fix 1a — a retired vendor entity closes the session (Slice 5.3
|
||||||
|
/// review).</b> Retail's own range watcher
|
||||||
|
/// (<c>CPlayerSystem::RegisterObjectRangeHandler</c>) dies with its
|
||||||
|
/// target — it has nothing left to watch once the vendor NPC is
|
||||||
|
/// retired (despawn, death, ObjectDelete during a recall). The prior
|
||||||
|
/// revision's early-return here on a failed <c>TryGetActive</c> was a
|
||||||
|
/// permissive default that stranded the session forever once the
|
||||||
|
/// vendor left the active entity set. A missing POSITION on a still-
|
||||||
|
/// active record gets the same treatment: a positionless vendor cannot
|
||||||
|
/// be range-checked, and retail's watcher likewise has nothing to
|
||||||
|
/// watch.
|
||||||
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Distance metric divergence (register AP-160):</b> retail/ACE close
|
/// <b>Distance metric divergence (register AP-160):</b> retail/ACE close
|
||||||
|
|
@ -59,6 +110,16 @@ public static class RuntimeVendorRangeQuery
|
||||||
if (vendorId == 0u)
|
if (vendorId == 0u)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Fix 1b: close unconditionally the instant an in-session transit
|
||||||
|
// has begun — see the class doc above for why HasPendingTeleportStart
|
||||||
|
// is the earliest observable "transit begin" edge for both hosts.
|
||||||
|
RuntimeWorldTransitState transit = runtime.TransitOwner;
|
||||||
|
if (transit.HasPendingTeleportStart || transit.IsTeleportActive)
|
||||||
|
{
|
||||||
|
vendor.Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
uint playerGuid = runtime.PlayerIdentity.ServerGuid;
|
uint playerGuid = runtime.PlayerIdentity.ServerGuid;
|
||||||
if (playerGuid == 0u
|
if (playerGuid == 0u
|
||||||
|| !runtime.EntityObjects.Entities.TryGetActive(
|
|| !runtime.EntityObjects.Entities.TryGetActive(
|
||||||
|
|
@ -69,15 +130,24 @@ public static class RuntimeVendorRangeQuery
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fix 1a: a retired vendor entity, or one left with no resolvable
|
||||||
|
// position, closes the session instead of stranding it open forever.
|
||||||
if (!runtime.EntityObjects.Entities.TryGetActive(
|
if (!runtime.EntityObjects.Entities.TryGetActive(
|
||||||
vendorId,
|
vendorId,
|
||||||
out RuntimeEntityRecord vendorRecord)
|
out RuntimeEntityRecord vendorRecord)
|
||||||
|| vendorRecord.Snapshot.Position is not { } vendorPosition)
|
|| vendorRecord.Snapshot.Position is not { } vendorPosition)
|
||||||
{
|
{
|
||||||
|
vendor.Close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
float useRadius = vendorRecord.Snapshot.UseRadius ?? DefaultUseRadius;
|
// Fix 4: retail passes the vendor's raw authored UseRadius with NO
|
||||||
|
// client-side fallback (see the class doc's ACE citation correction)
|
||||||
|
// — an absent/zero radius closes on the very first nonzero-distance
|
||||||
|
// check, since ObjectRangeMath.ObjectsInRange with range=0 requires
|
||||||
|
// an EXACT position match. That is retail's own behavior for a
|
||||||
|
// radius-0 (or unauthored) handler, not a bug to paper over.
|
||||||
|
float useRadius = vendorRecord.Snapshot.UseRadius ?? 0f;
|
||||||
bool inRange = ObjectRangeMath.ObjectsInRange(
|
bool inRange = ObjectRangeMath.ObjectsInRange(
|
||||||
AbsolutePosition(playerPosition),
|
AbsolutePosition(playerPosition),
|
||||||
0f,
|
0f,
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,13 @@ public sealed class VendorApproachTests
|
||||||
public void TryParse_TruncatedMidItemPrefix_ReturnsNull()
|
public void TryParse_TruncatedMidItemPrefix_ReturnsNull()
|
||||||
{
|
{
|
||||||
// The item's packed stack-size dword is present but its guid is
|
// The item's packed stack-size dword is present but its guid is
|
||||||
// cut off entirely — truncation inside the per-item PREFIX (before
|
// cut off entirely. This does NOT reach a guid read that fails --
|
||||||
// PublicWeenieDescParser is even reached) must fail the whole parse.
|
// TryParse's own minimum-size guard (`(long)itemCount * 12 >
|
||||||
|
// payload.Length - pos`, the smallest possible per-item size:
|
||||||
|
// packed(4) + guid(4) + weenieFlags(4)) rejects the whole parse
|
||||||
|
// right after itemCount is read (remaining=4 bytes here, the packed
|
||||||
|
// dword only; 1 * 12 = 12 > 4), before the per-item loop that would
|
||||||
|
// read the packed dword/guid ever runs.
|
||||||
var w = new AceWireWriter();
|
var w = new AceWireWriter();
|
||||||
WriteMinimalProfilePrefix(w, vendorGuid: 0x40000500u);
|
WriteMinimalProfilePrefix(w, vendorGuid: 0x40000500u);
|
||||||
w.Write(1u); // item count = 1
|
w.Write(1u); // item count = 1
|
||||||
|
|
|
||||||
|
|
@ -127,4 +127,48 @@ public sealed class VendorPricingTests
|
||||||
Assert.Equal(-1, VendorPricing.BuyPrice(-50, (uint)ItemType.Misc, 1.0f, 1));
|
Assert.Equal(-1, VendorPricing.BuyPrice(-50, (uint)ItemType.Misc, 1.0f, 1));
|
||||||
Assert.Equal(-1, VendorPricing.SellPrice(-50, (uint)ItemType.Misc, 1.0f, 1));
|
Assert.Equal(-1, VendorPricing.SellPrice(-50, (uint)ItemType.Misc, 1.0f, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- PerUnitValue (Slice 5.3 review fix 2) -----------------------------
|
||||||
|
// VendorProfile::VendorSellPrice/VendorBuyPrice (0x005D1B00/0x005D1B70,
|
||||||
|
// pc:484801-484813): stackSize <= 0 ? value : value / stackSize
|
||||||
|
// (INTEGER division of the wire's stack-TOTAL value by the item's own
|
||||||
|
// authored PublicWeenieDesc.StackSize).
|
||||||
|
|
||||||
|
// ---- 7. Stack of 50 arrows: the motivating case ------------------------
|
||||||
|
// Wire Value=500 is the price for the WHOLE stack of 50 arrows;
|
||||||
|
// per-unit must equal the single-arrow price of 10.
|
||||||
|
[Fact]
|
||||||
|
public void StackOf50Arrows_DividesToThePerArrowValue()
|
||||||
|
{
|
||||||
|
Assert.Equal(10, VendorPricing.PerUnitValue(500, descStackSize: 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 8. descStackSize <= 0 guard ----------------------------------------
|
||||||
|
// Zero and negative both take retail's "no division" branch — the
|
||||||
|
// stack-total value passes through unchanged.
|
||||||
|
[Fact]
|
||||||
|
public void DescStackSizeZeroOrNegative_ReturnsValueUnchanged()
|
||||||
|
{
|
||||||
|
Assert.Equal(250, VendorPricing.PerUnitValue(250, descStackSize: 0));
|
||||||
|
Assert.Equal(250, VendorPricing.PerUnitValue(250, descStackSize: -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 9. descStackSize absent (null) --------------------------------------
|
||||||
|
// A non-stackable item's wire PWD never carries a StackSize field at
|
||||||
|
// all; null must be treated exactly like retail's zeroed struct
|
||||||
|
// default (0) -- no division, value unchanged.
|
||||||
|
[Fact]
|
||||||
|
public void DescStackSizeAbsent_ReturnsValueUnchanged()
|
||||||
|
{
|
||||||
|
Assert.Equal(250, VendorPricing.PerUnitValue(250, descStackSize: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 10. Non-exact division truncates toward zero -----------------------
|
||||||
|
// 100 / 3 = 33.33... -> retail's plain integer divide truncates to 33,
|
||||||
|
// same as .NET's int division.
|
||||||
|
[Fact]
|
||||||
|
public void NonExactDivision_TruncatesTowardZero()
|
||||||
|
{
|
||||||
|
Assert.Equal(33, VendorPricing.PerUnitValue(100, descStackSize: 3));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,26 @@ public sealed class VendorStateTests
|
||||||
Assert.Equal(0u, change.VendorId);
|
Assert.Equal(0u, change.VendorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Close_ThrowingObserver_DoesNotPropagateAndStillClosesTheSession()
|
||||||
|
{
|
||||||
|
// Slice 5.3 review fix 3: unlike Reset(), Close() must never
|
||||||
|
// rethrow — its production caller (RuntimeVendorRangeQuery.
|
||||||
|
// EnforceRange) runs inside an unprotected per-frame callback.
|
||||||
|
var state = new VendorState();
|
||||||
|
state.Apply(0x40000007u, default, Array.Empty<VendorShopItem>());
|
||||||
|
|
||||||
|
bool secondObserverRan = false;
|
||||||
|
state.Changed += _ => throw new InvalidOperationException("boom");
|
||||||
|
state.Changed += _ => secondObserverRan = true;
|
||||||
|
|
||||||
|
Exception? thrown = Record.Exception(() => { state.Close(); });
|
||||||
|
|
||||||
|
Assert.Null(thrown);
|
||||||
|
Assert.True(secondObserverRan);
|
||||||
|
Assert.Equal(0u, state.VendorId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother()
|
public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -81,26 +81,136 @@ public sealed class RuntimeVendorRangeQueryTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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();
|
using GameRuntime runtime = Create();
|
||||||
runtime.PlayerIdentity.ServerGuid = Player;
|
runtime.PlayerIdentity.ServerGuid = Player;
|
||||||
RuntimeEntityRecord playerRecord =
|
RuntimeEntityRecord playerRecord =
|
||||||
Add(runtime, Player, Landblock, 100f, 100f);
|
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);
|
Open(runtime, Vendor);
|
||||||
|
|
||||||
// 0.5 m: inside the 0.6 m fallback.
|
// Exact same position: distance 0 <= radius 0 — still open.
|
||||||
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
||||||
Assert.Equal(Vendor, runtime.InventoryOwner.Vendor.VendorId);
|
Assert.Equal(Vendor, runtime.InventoryOwner.Vendor.VendorId);
|
||||||
|
|
||||||
// Walk to 2 m: outside the 0.6 m fallback.
|
// Any nonzero move at all — even 5 cm — is out of range at radius 0.
|
||||||
SetPosition(playerRecord, Landblock, 102.5f, 100f);
|
SetPosition(playerRecord, Landblock, 100.05f, 100f);
|
||||||
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
||||||
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
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) =>
|
private static void Open(GameRuntime runtime, uint vendorGuid) =>
|
||||||
Assert.True(runtime.InventoryOwner.Vendor.Apply(
|
Assert.True(runtime.InventoryOwner.Vendor.Apply(
|
||||||
vendorGuid,
|
vendorGuid,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue