fix(vendor): grand-gate findings — wire-truth container counts, the live split bar, arrival-gated use, prepend-order race
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
Four live findings, each with the paper-verification failure named: G1 the container-capacity guard counted containers by a local type/capacity heuristic that over-classifies ordinary items; retail buckets from the wire's ContainerProperties at insert. Now reads ClientObjectTable's existing ContainerTypeHint (AP-168 narrowed to the shop-stock half; a pre-check must never false-block). G2 the amount bar never showed live because ACE never sets StackSize on browse listings — DescStackSize is null for every real vendor item and the C4 paper test hand-set the field, bypassing the materializer. The materializer now falls back to the packed supply count (AP-169, ACE adaptation); the new test drives the REAL materializer. G3 an out-of-range Use now dispatches ON ARRIVAL (pickup's shape): ACE's HandleActionUseItem only opens the vendor when the Use finds the player in range — a click-time send is greeted and dropped (AP-170, ACE adaptation; retail's server walks the player, ACE does not). G4 bought items appended because ACE's placement echo (UIQueue) can beat the CreateObject (SmartboxQueue) — cross-queue, no ordering guarantee — and the early echo was silently dropped. ClientObjectTable now stashes unresolved placements and replays them at Ingest: buys land at the retail list head. No register row — this RESTORES parity. Clean-room complete solution: 11,521 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c68ad1e646
commit
68568a3a59
12 changed files with 1140 additions and 90 deletions
File diff suppressed because one or more lines are too long
|
|
@ -214,34 +214,49 @@ internal sealed class SelectionInteractionController
|
|||
public void SendUse(uint serverGuid)
|
||||
=> RequestUse(serverGuid, reservation: null);
|
||||
|
||||
/// <summary>
|
||||
/// G3 (grand-gate finding, 2026-08-08, register AP-170): an out-of-range
|
||||
/// Use no longer sends the wire request immediately — it arms on the
|
||||
/// SAME arrival-gated shape <see cref="SendPickup"/>'s close-range
|
||||
/// (turn-only) branch already uses, dispatching only once the approach
|
||||
/// naturally completes.
|
||||
/// <para>
|
||||
/// <b>Why this deviates from retail's own literal
|
||||
/// <c>ItemHolder::UseObject @ 0x00588A80</c> send-immediately shape.</b>
|
||||
/// Retail's REAL server walks the player itself before the target's
|
||||
/// <c>ActOnUse</c> handler ever sees the request — the client is free to
|
||||
/// fire immediately because the server-side arrival gate is invisible to
|
||||
/// it. ACE does not do this for a player-initiated Use: live testing
|
||||
/// against the user's local ACE server (2026-08-08) showed a vendor
|
||||
/// approached from out of range plays its cosmetic greeting (a
|
||||
/// distance-only reaction independent of the Use action) but never opens
|
||||
/// the shop panel — <c>ApproachVendor</c> never arrives. ACE's own
|
||||
/// <c>Player.HandleActionUseItem</c> (<c>Player_Use.cs:176-215</c>)
|
||||
/// confirms why: an out-of-range target routes through
|
||||
/// <c>CreateMoveToChain(item, (success) => TryUseItem(item, success))</c>
|
||||
/// (<c>Player_Move.cs:37-96</c>), which POLLS every 0.1s for the player
|
||||
/// to reach <c>WithinUseRadius</c> and only then calls
|
||||
/// <c>TryUseItem</c>/<c>ActOnUse</c> — it does not teleport or
|
||||
/// server-move the player; it waits for the client's own walk to land.
|
||||
/// Sending the wire Use before OUR client has actually arrived races
|
||||
/// that poll and can lose. Retail's client-side immediacy assumption
|
||||
/// (this method's ORIGINAL design, see the register) does not hold
|
||||
/// against this server; arming on arrival closes the gap by construction
|
||||
/// instead of racing it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// F11 (Slice 6b/6c review, preserved): the eligibility test
|
||||
/// (<c>ownedByPlayer || useable</c>) is still computed ONCE, up front,
|
||||
/// before any approach or arm — an ineligible target never kicks off a
|
||||
/// wasted walk.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void RequestUse(
|
||||
uint serverGuid,
|
||||
ItemUseRequestReservation? reservation)
|
||||
{
|
||||
CancelPendingApproach();
|
||||
// ItemHolder::UseObject @ 0x00588A80 has no distance/range check —
|
||||
// retail's client sends Use unconditionally regardless of range; the
|
||||
// walk-in is entirely server-driven (ACE's CreateMoveToChain,
|
||||
// Player_Move.cs:37-65) and arrives back as an ordinary broadcast
|
||||
// motion command (Q2, docs/research/2026-08-08-slice6b-vendor-
|
||||
// completion-research.md). This mirrors SendPickup's !IsCloseRange
|
||||
// branch below: kick off the SAME local client-predicted
|
||||
// MoveToObject animation for immediate visual feel, but never gate
|
||||
// the wire send on its arrival — unlike Pickup's close-range
|
||||
// TurnToObject branch, Use keeps sending immediately either way (the
|
||||
// existing RuntimeInteractionTransactionState.TryDispatchUse doc
|
||||
// comment: "consume the strict 0.2-second gate, send immediately").
|
||||
//
|
||||
// F11 (Slice 6b/6c review): the eligibility TryDispatchUse itself
|
||||
// gates on (ownedByPlayer || useable) is computed ONCE, up front,
|
||||
// and checked BEFORE BeginApproach — a prior version of this method
|
||||
// called BeginApproach unconditionally whenever the target was out
|
||||
// of close range, kicking off a client-predicted walk toward a
|
||||
// target the dispatch below was always going to refuse anyway (a
|
||||
// wasted, visually confusing approach with no possible Use at the
|
||||
// end of it). Reordering does not change the dispatch itself: an
|
||||
// ELIGIBLE target still sends immediately, in the same order,
|
||||
// exactly as before.
|
||||
|
||||
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
|
||||
bool useable = ownedByPlayer || _query.IsUseable(serverGuid);
|
||||
|
||||
|
|
@ -249,9 +264,48 @@ internal sealed class SelectionInteractionController
|
|||
&& _query.TryGetApproach(serverGuid, out InteractionApproach approach)
|
||||
&& !approach.IsCloseRange)
|
||||
{
|
||||
_movement.BeginApproach(approach);
|
||||
// Genuinely out of range (a real walk, not just a turn) —
|
||||
// mirror SendPickup's arrival-gated shape: arm the transaction
|
||||
// on the approach token BEFORE the movement starts (so a
|
||||
// synchronously-completing approach can't race the arm), then
|
||||
// let HandleApproachCompletion dispatch on natural arrival.
|
||||
bool armed = false;
|
||||
bool started = _movement.BeginApproach(
|
||||
approach,
|
||||
token =>
|
||||
{
|
||||
armed = _transactions.TryArmPostArrivalUse(
|
||||
serverGuid,
|
||||
ownedByPlayer,
|
||||
useable,
|
||||
reservation,
|
||||
new RuntimeInteractionApproachToken(
|
||||
token.ControllerLifetime,
|
||||
token.ApproachGeneration),
|
||||
out _);
|
||||
});
|
||||
if (!started || !armed)
|
||||
{
|
||||
// Release whatever got captured (or the caller's own
|
||||
// reservation, if arming never stored it) — mirrors
|
||||
// SendPickup's !started/!armed cleanup shape.
|
||||
if (_transactions.TryCancelPendingUse(
|
||||
serverGuid, out RuntimePendingUse cancelled))
|
||||
{
|
||||
cancelled.Reservation?.CancelBeforeDispatch();
|
||||
}
|
||||
else
|
||||
{
|
||||
reservation?.CancelBeforeDispatch();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Already in range (a turn at most, or no approach concept applies)
|
||||
// — keep retail's immediate send; ACE's own "already within use
|
||||
// distance" branch (Player_Move.cs:65-87) calls back synchronously,
|
||||
// so there is no arrival gap to race here.
|
||||
RuntimeInteractionDispatchResult result =
|
||||
_transactions.TryDispatchUse(
|
||||
serverGuid,
|
||||
|
|
@ -407,20 +461,44 @@ internal sealed class SelectionInteractionController
|
|||
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
|
||||
public void OnNaturalMoveToComplete()
|
||||
{
|
||||
if (_transactions.TryGetPendingPickup(out RuntimePendingPickup pending))
|
||||
HandleApproachCompletion(pending.ApproachToken, natural: true);
|
||||
if (_transactions.TryGetPendingPickup(out RuntimePendingPickup pendingPickup))
|
||||
{
|
||||
HandleApproachCompletion(pendingPickup.ApproachToken, natural: true);
|
||||
return;
|
||||
}
|
||||
// G3: at most one of {pendingPickup, pendingUse} is ever armed —
|
||||
// CancelPendingApproach() clears any prior one before a new
|
||||
// SendPickup/RequestUse arms another.
|
||||
if (_transactions.TryGetPendingUse(out RuntimePendingUse pendingUse))
|
||||
HandleApproachCompletion(pendingUse.ApproachToken, natural: true);
|
||||
}
|
||||
|
||||
private void HandleApproachCompletion(
|
||||
RuntimeInteractionApproachToken approachToken,
|
||||
bool natural)
|
||||
{
|
||||
bool accepted = _transactions.TryResolveApproachCompletion(
|
||||
bool pickupAccepted = _transactions.TryResolveApproachCompletion(
|
||||
approachToken,
|
||||
natural,
|
||||
out RuntimePendingPickup pending);
|
||||
if (pending.Token == 0u)
|
||||
out RuntimePendingPickup pendingPickup);
|
||||
if (pendingPickup.Token != 0u)
|
||||
{
|
||||
HandlePickupApproachCompletion(pendingPickup, pickupAccepted);
|
||||
return;
|
||||
}
|
||||
|
||||
bool useAccepted = _transactions.TryResolveUseApproachCompletion(
|
||||
approachToken,
|
||||
natural,
|
||||
out RuntimePendingUse pendingUse);
|
||||
if (pendingUse.Token != 0u)
|
||||
HandleUseApproachCompletion(pendingUse, useAccepted);
|
||||
}
|
||||
|
||||
private void HandlePickupApproachCompletion(
|
||||
RuntimePendingPickup pending,
|
||||
bool accepted)
|
||||
{
|
||||
if (!accepted)
|
||||
{
|
||||
CancelPickupPresentation(
|
||||
|
|
@ -455,6 +533,41 @@ internal sealed class SelectionInteractionController
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G3: dispatches an armed Use on natural arrival. A cancelled approach
|
||||
/// (<paramref name="accepted"/> false — supersede/move-away) releases
|
||||
/// the reservation directly; <see cref="RuntimeInteractionTransactionState.TryDispatchUse"/>
|
||||
/// already resolves the reservation on every one of its own outcomes
|
||||
/// (dispatched or rejected), so no separate release is needed past that
|
||||
/// point.
|
||||
/// </summary>
|
||||
private void HandleUseApproachCompletion(
|
||||
RuntimePendingUse pending,
|
||||
bool accepted)
|
||||
{
|
||||
if (!accepted)
|
||||
{
|
||||
pending.Reservation?.CancelBeforeDispatch();
|
||||
return;
|
||||
}
|
||||
|
||||
RuntimeInteractionDispatchResult result =
|
||||
_transactions.TryDispatchUse(
|
||||
pending.ServerGuid,
|
||||
pending.OwnedByPlayer,
|
||||
pending.Useable,
|
||||
pending.Reservation,
|
||||
_transport,
|
||||
out uint sequence);
|
||||
if (result == RuntimeInteractionDispatchResult.NotInWorld)
|
||||
_toast?.Invoke("Not in world");
|
||||
if (result == RuntimeInteractionDispatchResult.Dispatched)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[B.4b] use guid=0x{pending.ServerGuid:X8} seq={sequence} (arrival-gated)");
|
||||
}
|
||||
}
|
||||
|
||||
public void DrainOutbound()
|
||||
{
|
||||
while (_approachCompletions.TryTake(out PlayerApproachCompletion completion))
|
||||
|
|
@ -482,6 +595,11 @@ internal sealed class SelectionInteractionController
|
|||
cancelled.ServerGuid,
|
||||
cancelled.PendingPlacementToken);
|
||||
}
|
||||
// G3: an armed out-of-range Use whose target vanished must release
|
||||
// its reservation too — the approach it was waiting on will never
|
||||
// naturally complete against a hidden target.
|
||||
if (_transactions.TryCancelPendingUse(serverGuid, out RuntimePendingUse cancelledUse))
|
||||
cancelledUse.Reservation?.CancelBeforeDispatch();
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
{
|
||||
_selection.Clear(
|
||||
|
|
@ -505,6 +623,10 @@ internal sealed class SelectionInteractionController
|
|||
cancelled.ServerGuid,
|
||||
cancelled.PendingPlacementToken);
|
||||
}
|
||||
// G3: same as OnEntityHidden — a removed target's armed Use must
|
||||
// not linger waiting for an approach that can never complete.
|
||||
if (_transactions.TryCancelPendingUse(record.ServerGuid, out RuntimePendingUse cancelledUse))
|
||||
cancelledUse.Reservation?.CancelBeforeDispatch();
|
||||
if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid)
|
||||
{
|
||||
_selection.Clear(
|
||||
|
|
@ -634,13 +756,19 @@ internal sealed class SelectionInteractionController
|
|||
|
||||
private void CancelPendingApproach()
|
||||
{
|
||||
if (!_transactions.TryCancelPendingPickup(
|
||||
if (_transactions.TryCancelPendingPickup(
|
||||
out RuntimePendingPickup pending))
|
||||
return;
|
||||
{
|
||||
CancelPickupPresentation(
|
||||
pending.ServerGuid,
|
||||
pending.PendingPlacementToken);
|
||||
}
|
||||
// G3: a new SendPickup/RequestUse supersedes whatever approach was
|
||||
// previously armed — release an in-flight Use's reservation too, not
|
||||
// just pickup's presentation token.
|
||||
if (_transactions.TryCancelPendingUse(out RuntimePendingUse pendingUse))
|
||||
pendingUse.Reservation?.CancelBeforeDispatch();
|
||||
}
|
||||
|
||||
private void DispatchQueuedInteraction(
|
||||
RuntimeQueuedInteraction interaction)
|
||||
|
|
|
|||
|
|
@ -1623,16 +1623,28 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// reference <c>0x007b57b4</c>; <c>pc:204056</c>/<c>204068</c> both
|
||||
/// reference <c>0x007b5750</c> via the shared <c>label_4c5509</c>).
|
||||
/// <para>
|
||||
/// <b>Container-vs-item slot classification (register AP-168).</b>
|
||||
/// Retail's own split tests a bitfield bit this codebase does not
|
||||
/// currently thread onto <see cref="VendorShopItem"/>
|
||||
/// (<c>gmVendorUI::InqListSlotCount</c>, <c>pc:200038-200065</c>) — this
|
||||
/// port approximates "is this shop item a container" with
|
||||
/// <see cref="ItemType.Container"/> instead, correct for the ordinary
|
||||
/// case (a real backpack/pouch DOES carry that type bit) but not
|
||||
/// byte-identical for the theoretical case of a non-<c>Container</c>-typed
|
||||
/// item that still authors nonzero pack/side capacities. See the
|
||||
/// register.
|
||||
/// <b>Container-vs-item slot classification (register AP-168, narrowed
|
||||
/// G1 gate-finding fix 2026-08-08).</b> Retail's own split tests a
|
||||
/// bitfield bit this codebase does not currently thread onto
|
||||
/// <see cref="VendorShopItem"/> (<c>gmVendorUI::InqListSlotCount</c>,
|
||||
/// <c>pc:200038-200065</c>) — this port approximates "is this shop item
|
||||
/// a container" with <see cref="ItemType.Container"/> instead, correct
|
||||
/// for the ordinary case (a real backpack/pouch DOES carry that type
|
||||
/// bit) but not byte-identical for the theoretical case of a
|
||||
/// non-<c>Container</c>-typed item that still authors nonzero pack/side
|
||||
/// capacities. This residual applies ONLY to the shop-stock side
|
||||
/// (<see cref="ComputeBuySlotsNeeded"/>) — <see cref="VendorShopItem"/>
|
||||
/// genuinely has no wire-carried classification field to read instead.
|
||||
/// The player's-OWN-pack side (<see cref="CountPlayerContents"/>) no
|
||||
/// longer shares this approximation: it now reads
|
||||
/// <see cref="ClientObject.ContainerTypeHint"/> (retail's actual wire
|
||||
/// <c>ContainerProperties</c>, already threaded onto every owned object)
|
||||
/// first, matching retail's real <c>_itemsList</c>/<c>_containersList</c>
|
||||
/// bucketing exactly for anything that ever received a hint. Live
|
||||
/// testing showed the OLD dual-heuristic (also checking nonzero
|
||||
/// <c>ItemsCapacity</c>/<c>ContainersCapacity</c>) could over-classify a
|
||||
/// non-container object as an occupied container slot and false-block a
|
||||
/// purchase with real free slots — see the register.
|
||||
/// </para>
|
||||
/// On a successful DISPATCH the whole staged list is flushed
|
||||
/// UNCONDITIONALLY and immediately, matching retail's literal order:
|
||||
|
|
@ -1733,7 +1745,35 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
return (itemSlots, containerSlots);
|
||||
}
|
||||
|
||||
/// <summary>F1: the player's CURRENT occupied item/container slot counts.</summary>
|
||||
/// <summary>
|
||||
/// F1 (G1 gate-finding fix, 2026-08-08): the player's CURRENT occupied
|
||||
/// item/container slot counts. Retail's own <c>GetNumContainedItems</c>/
|
||||
/// <c>GetNumContainedContainers</c> (<c>0x0058beb0</c>/<c>0x0058bec0</c>)
|
||||
/// don't reclassify anything at count time — they just report the length
|
||||
/// of two ALREADY-BUCKETED <c>IDList</c>s (<c>_itemsList</c>/
|
||||
/// <c>_containersList</c>). The bucketing happens once, at INSERT time
|
||||
/// (<c>ACCWeenieObject::ServerSaysContainID @ 0x0058be40</c>), from the
|
||||
/// wire's own <c>ContainerProperties</c> field (<c>Item_ServerSaysContainId</c>
|
||||
/// 0x0022's <c>ContainerType</c>; also carried by <c>ContentProfile</c> /
|
||||
/// <c>PlayerDescription</c>'s per-entry container-kind byte) — a
|
||||
/// None/Container/Foci discriminator the SERVER computes, not something
|
||||
/// the client reverse-engineers from the item's own type bits.
|
||||
/// <see cref="ClientObject.ContainerTypeHint"/> is exactly that wire
|
||||
/// field, already threaded through every membership path
|
||||
/// (<c>InitializeInventoryManifest</c>, <c>InventoryPutObjInContainer</c>,
|
||||
/// <c>ViewContents</c>) and already used for this identical
|
||||
/// container-vs-item question elsewhere
|
||||
/// (<c>ClientObjectTable.IsContainerListMember</c>). This port previously
|
||||
/// used ONLY the local <see cref="ItemType.Container"/>/capacity-field
|
||||
/// heuristic here (AP-168) and never consulted the hint — live testing
|
||||
/// showed that guessing wrong in the OVER-classify direction (a
|
||||
/// non-container object whose capacity fields happen to read nonzero)
|
||||
/// false-blocks a purchase with real free slots (#G1). The hint is now
|
||||
/// authoritative when present; the heuristic is a narrower fallback
|
||||
/// (Container-typed only, matching <see cref="ComputeBuySlotsNeeded"/>'s
|
||||
/// single signal) for the rare object that reached the table without
|
||||
/// ever threading a hint.
|
||||
/// </summary>
|
||||
private (int Items, int Containers) CountPlayerContents()
|
||||
{
|
||||
int items = 0, containers = 0;
|
||||
|
|
@ -1741,9 +1781,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
{
|
||||
ClientObject? obj = _objects.Get(guid);
|
||||
bool isContainer = obj is not null
|
||||
&& (obj.ItemsCapacity > 0
|
||||
|| obj.ContainersCapacity > 0
|
||||
|| (obj.Type & ItemType.Container) != 0);
|
||||
&& (obj.ContainerTypeHint != 0u || (obj.Type & ItemType.Container) != 0);
|
||||
if (isContainer) containers++; else items++;
|
||||
}
|
||||
return (items, containers);
|
||||
|
|
|
|||
|
|
@ -123,6 +123,28 @@ public sealed class ClientObjectTable
|
|||
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
|
||||
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
|
||||
private readonly Dictionary<uint, (ClientObjectPlacement placement, int outstanding)> _pendingMoves = new();
|
||||
|
||||
/// <summary>
|
||||
/// G4 (grand-gate finding, 2026-08-08, register AP-171): an authoritative
|
||||
/// placement (<see cref="ApplyServerMove"/>/<see cref="ApplyConfirmedServerMove"/>)
|
||||
/// whose item guid does not exist in <see cref="_objects"/> YET, keyed by
|
||||
/// that guid — stashed instead of silently dropped, and replayed by
|
||||
/// <see cref="Ingest"/> the moment that guid's CreateObject arrives.
|
||||
/// <para>
|
||||
/// ACE's <c>GameMessageCreateObject</c> rides <c>GameMessageGroup.SmartboxQueue</c>
|
||||
/// while <c>Item_ServerSaysContainId</c>/<c>InventoryPutObjInContainer</c>
|
||||
/// (0x0022) rides <c>GameMessageGroup.UIQueue</c> — two independent
|
||||
/// reliable queues with NO cross-queue ordering guarantee. An ordinary
|
||||
/// pickup's item guid is already known (it was visible in the 3D world
|
||||
/// first), so this race can't bite it; a vendor BUY of common stock
|
||||
/// mints a brand-new guid the client has never seen
|
||||
/// (<c>Player_Commerce.cs</c> <c>ItemProfileToWorldObjects</c>), so its
|
||||
/// existence depends entirely on which queue's message the client
|
||||
/// processes first.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private readonly Dictionary<uint, (ClientObjectPlacement Placement, uint? ContainerTypeHint)>
|
||||
_pendingUnresolvedPlacements = new();
|
||||
private ulong _mutationRevision;
|
||||
|
||||
public ClientObjectTable()
|
||||
|
|
@ -451,6 +473,19 @@ public sealed class ClientObjectTable
|
|||
/// request is reconciled before <see cref="ObjectMoved"/> is published,
|
||||
/// matching retail <c>ServerSaysMoveItem</c>: a reentrant listener may start
|
||||
/// a new request without the old confirmation consuming it afterward.
|
||||
/// <para>
|
||||
/// G4 (grand-gate finding, register AP-171): if <paramref name="itemId"/>
|
||||
/// does not exist yet — the cross-queue race documented on
|
||||
/// <see cref="_pendingUnresolvedPlacements"/>, most visibly a vendor
|
||||
/// buy's brand-new guid whose <c>InventoryPutObjInContainer</c> (UIQueue)
|
||||
/// echo can arrive before its own <c>CreateObject</c> (SmartboxQueue) —
|
||||
/// the requested placement is STASHED rather than silently dropped, and
|
||||
/// replayed the moment <see cref="Ingest"/> creates that guid. Without
|
||||
/// this, the item's placement=0 request is lost, and its later
|
||||
/// CreateObject-only <c>Ingest</c> leaves it wherever the naive
|
||||
/// container-index append put it (the list tail) instead of retail's
|
||||
/// requested slot 0 (list head).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool ApplyConfirmedServerMove(
|
||||
uint itemId,
|
||||
|
|
@ -472,6 +507,17 @@ public sealed class ClientObjectTable
|
|||
return true;
|
||||
}
|
||||
|
||||
if (itemId != 0u && newContainerId != 0u && !_objects.ContainsKey(itemId))
|
||||
{
|
||||
_pendingUnresolvedPlacements[itemId] = (
|
||||
new ClientObjectPlacement(
|
||||
newContainerId,
|
||||
newSlot,
|
||||
newWielderId,
|
||||
newEquipLocation),
|
||||
containerTypeHint);
|
||||
}
|
||||
|
||||
ObjectMoved?.Invoke(new ClientObjectMove(
|
||||
itemId,
|
||||
Item: null,
|
||||
|
|
@ -929,6 +975,28 @@ public sealed class ClientObjectTable
|
|||
UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj));
|
||||
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
|
||||
PublishContainerContentsChanges(changedContainers);
|
||||
|
||||
// G4: this guid's CreateObject just arrived. If an authoritative
|
||||
// placement (Item_ServerSaysContainId/InventoryPutObjInContainer)
|
||||
// for it had already arrived and been stashed — the cross-queue
|
||||
// race documented on _pendingUnresolvedPlacements — replay it now
|
||||
// via the SAME ordered-insert path ApplyServerMove already uses, so
|
||||
// the retail-requested slot (e.g. 0 — the pack head) wins over the
|
||||
// naive append Reindex just performed above. Only ever possible for
|
||||
// a brand-new guid: an existing object's ApplyConfirmedServerMove
|
||||
// would have found it immediately and never stashed anything.
|
||||
if (!existed
|
||||
&& _pendingUnresolvedPlacements.Remove(
|
||||
d.Guid, out var pending))
|
||||
{
|
||||
ApplyServerMove(
|
||||
d.Guid,
|
||||
pending.Placement.ContainerId,
|
||||
pending.Placement.WielderId,
|
||||
pending.Placement.ContainerSlot,
|
||||
pending.Placement.EquipLocation,
|
||||
pending.ContainerTypeHint);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
|
@ -1506,6 +1574,7 @@ public sealed class ClientObjectTable
|
|||
_containerIndex.Clear();
|
||||
_equipmentIndex.Clear();
|
||||
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
|
||||
_pendingUnresolvedPlacements.Clear(); // G4: drop stashed placements for a session that's ending anyway
|
||||
Cleared?.Invoke();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,23 @@ public readonly record struct RuntimePendingPickup(
|
|||
ulong PendingPlacementToken,
|
||||
RuntimeInteractionApproachToken ApproachToken);
|
||||
|
||||
/// <summary>
|
||||
/// G3 (grand-gate finding): an out-of-range Use armed to dispatch on
|
||||
/// arrival, mirroring <see cref="RuntimePendingPickup"/>'s shape. Holds the
|
||||
/// eligibility snapshot computed at request time (retail's own
|
||||
/// <c>ItemHolder::UseObject</c> eligibility test runs once, before the
|
||||
/// walk-in) plus the caller's <see cref="ItemUseRequestReservation"/>, which
|
||||
/// crosses the approach boundary unresolved until arrival (dispatch) or
|
||||
/// cancellation.
|
||||
/// </summary>
|
||||
public readonly record struct RuntimePendingUse(
|
||||
ulong Token,
|
||||
uint ServerGuid,
|
||||
bool OwnedByPlayer,
|
||||
bool Useable,
|
||||
ItemUseRequestReservation? Reservation,
|
||||
RuntimeInteractionApproachToken ApproachToken);
|
||||
|
||||
public readonly record struct RuntimeAppraisalResponseAcceptance(
|
||||
bool Accepted,
|
||||
bool FirstResponse);
|
||||
|
|
@ -53,7 +70,13 @@ public readonly record struct RuntimeInteractionTransactionSnapshot(
|
|||
int OutboundCount,
|
||||
bool HasPendingPickup,
|
||||
ulong PendingPickupToken,
|
||||
long DispatchFailureCount)
|
||||
long DispatchFailureCount,
|
||||
// G3 (grand-gate finding): an armed out-of-range Use holds a live
|
||||
// ItemUseRequestReservation (a busy-count reference) until arrival or
|
||||
// cancellation resolves it — it must reach zero at teardown exactly
|
||||
// like HasPendingPickup.
|
||||
bool HasPendingUse = false,
|
||||
ulong PendingUseToken = 0u)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
|
|
@ -62,7 +85,8 @@ public readonly record struct RuntimeInteractionTransactionSnapshot(
|
|||
&& AwaitingAppraisalId == 0u
|
||||
&& CurrentAppraisalId == 0u
|
||||
&& OutboundCount == 0
|
||||
&& !HasPendingPickup;
|
||||
&& !HasPendingPickup
|
||||
&& !HasPendingUse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -71,10 +95,23 @@ public readonly record struct RuntimeInteractionTransactionSnapshot(
|
|||
/// <see cref="InventoryTransactionState"/> and is borrowed exactly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ordinary Use follows <c>ItemHolder::UseObject @ 0x00588A80</c>: consume the
|
||||
/// strict 0.2-second gate, send immediately, then transfer the busy reference
|
||||
/// to <c>ClientUISystem::Handle_Item__UseDone @ 0x00564900</c>. Pickup keeps
|
||||
/// the existing local approach transaction and exact post-arrival token.
|
||||
/// Ordinary (already-in-range) Use follows <c>ItemHolder::UseObject @
|
||||
/// 0x00588A80</c>: consume the strict 0.2-second gate, send immediately,
|
||||
/// then transfer the busy reference to <c>ClientUISystem::Handle_Item__UseDone
|
||||
/// @ 0x00564900</c>. Pickup keeps the existing local approach transaction and
|
||||
/// exact post-arrival token.
|
||||
/// <para>
|
||||
/// G3 (grand-gate finding, 2026-08-08): an OUT-OF-RANGE Use does NOT send
|
||||
/// immediately — ACE's <c>Player.HandleActionUseItem</c>
|
||||
/// (<c>Player_Use.cs:176-215</c>) only calls <c>ActOnUse</c> once its own
|
||||
/// <c>CreateMoveToChain</c> confirms the player is within the target's use
|
||||
/// radius; a Use that arrives while still out of range never opens the
|
||||
/// vendor panel (see register AP-170). <see cref="TryArmPostArrivalUse"/>/
|
||||
/// <see cref="TryResolveUseApproachCompletion"/> mirror
|
||||
/// <see cref="RuntimePendingPickup"/>'s exact arrival-gated shape for this
|
||||
/// case; an already-in-range Use is unaffected and still dispatches via
|
||||
/// <see cref="TryDispatchUse"/> immediately.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RuntimeInteractionTransactionState : IDisposable
|
||||
{
|
||||
|
|
@ -89,6 +126,8 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
private uint _currentAppraisalId;
|
||||
private RuntimePendingPickup? _pendingPickup;
|
||||
private ulong _nextPickupToken;
|
||||
private RuntimePendingUse? _pendingUse;
|
||||
private ulong _nextUseToken;
|
||||
private uint _clearEpoch;
|
||||
private long _revision;
|
||||
private long _dispatchFailureCount;
|
||||
|
|
@ -106,6 +145,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
public uint CurrentAppraisalId => _currentAppraisalId;
|
||||
public int OutboundCount => _outbound.Count;
|
||||
public bool HasPendingPickup => _pendingPickup is not null;
|
||||
public bool HasPendingUse => _pendingUse is not null;
|
||||
public bool IsDisposed => _disposed;
|
||||
public long Revision => Interlocked.Read(ref _revision);
|
||||
public long DispatchFailureCount =>
|
||||
|
|
@ -122,7 +162,9 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
_outbound.Count,
|
||||
_pendingPickup is not null,
|
||||
_pendingPickup?.Token ?? 0u,
|
||||
DispatchFailureCount);
|
||||
DispatchFailureCount,
|
||||
_pendingUse is not null,
|
||||
_pendingUse?.Token ?? 0u);
|
||||
|
||||
public bool TryConsumeUseThrottle(long nowMs)
|
||||
{
|
||||
|
|
@ -517,6 +559,124 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
return dispatched;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G3: arms an out-of-range Use to dispatch once the approach completes
|
||||
/// naturally, mirroring <see cref="TryArmPostArrivalPickup"/>. The
|
||||
/// <paramref name="reservation"/> (if any) crosses the approach boundary
|
||||
/// unresolved — it is released only by
|
||||
/// <see cref="TryResolveUseApproachCompletion"/> (via the caller's
|
||||
/// dispatch/cancel), <see cref="TryCancelPendingUse(out RuntimePendingUse)"/>,
|
||||
/// or a reset/dispose, never here.
|
||||
/// </summary>
|
||||
public bool TryArmPostArrivalUse(
|
||||
uint serverGuid,
|
||||
bool ownedByPlayer,
|
||||
bool useable,
|
||||
ItemUseRequestReservation? reservation,
|
||||
RuntimeInteractionApproachToken approachToken,
|
||||
out RuntimePendingUse pending)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (serverGuid == 0u
|
||||
|| approachToken.ControllerLifetime == 0u
|
||||
|| approachToken.ApproachGeneration == 0u)
|
||||
{
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
if (_pendingUse is not null)
|
||||
{
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
ulong token = ++_nextUseToken;
|
||||
if (token == 0u)
|
||||
token = ++_nextUseToken;
|
||||
pending = new RuntimePendingUse(
|
||||
token,
|
||||
serverGuid,
|
||||
ownedByPlayer,
|
||||
useable,
|
||||
reservation,
|
||||
approachToken);
|
||||
_pendingUse = pending;
|
||||
IncrementRevision();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G3: resolves an armed Use's approach completion, mirroring
|
||||
/// <see cref="TryResolveApproachCompletion"/>. The caller is responsible
|
||||
/// for dispatching (via <see cref="TryDispatchUse"/>, which itself
|
||||
/// resolves <see cref="RuntimePendingUse.Reservation"/>) or cancelling
|
||||
/// (<see cref="ItemUseRequestReservation.CancelBeforeDispatch"/>)
|
||||
/// depending on the returned bool and whether the target is still
|
||||
/// current.
|
||||
/// </summary>
|
||||
public bool TryResolveUseApproachCompletion(
|
||||
RuntimeInteractionApproachToken approachToken,
|
||||
bool natural,
|
||||
out RuntimePendingUse pending)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_pendingUse is not { } current
|
||||
|| current.ApproachToken != approachToken)
|
||||
{
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
_pendingUse = null;
|
||||
pending = current;
|
||||
IncrementRevision();
|
||||
return natural;
|
||||
}
|
||||
|
||||
public bool TryGetPendingUse(out RuntimePendingUse pending)
|
||||
{
|
||||
if (_pendingUse is { } current)
|
||||
{
|
||||
pending = current;
|
||||
return true;
|
||||
}
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Unconditional cancel — used when a NEW approach supersedes whatever was armed.</summary>
|
||||
public bool TryCancelPendingUse(out RuntimePendingUse pending)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_pendingUse is not { } current)
|
||||
{
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
_pendingUse = null;
|
||||
pending = current;
|
||||
IncrementRevision();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Guid-matching cancel — used when the target itself vanishes (hidden/removed).</summary>
|
||||
public bool TryCancelPendingUse(
|
||||
uint serverGuid,
|
||||
out RuntimePendingUse pending)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_pendingUse is not { } current
|
||||
|| current.ServerGuid != serverGuid)
|
||||
{
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
_pendingUse = null;
|
||||
pending = current;
|
||||
IncrementRevision();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
|
@ -538,16 +698,25 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
|| _currentAppraisalId != 0u
|
||||
|| _outbound.Count != 0
|
||||
|| _pendingPickup is not null
|
||||
|| _pendingUse is not null
|
||||
|| _lastUseSourceId != 0u
|
||||
|| _lastUseTargetId != 0u
|
||||
|| _lastUseMs != long.MinValue / 2;
|
||||
|
||||
// G3: an armed Use's reservation is a live busy-count reference —
|
||||
// release it here unconditionally so a reset/dispose that runs
|
||||
// without a preceding CancelPendingApproach() (e.g. a headless/
|
||||
// no-window teardown with no SelectionInteractionController) can
|
||||
// never leak it. Idempotent: a no-op if already resolved.
|
||||
_pendingUse?.Reservation?.CancelBeforeDispatch();
|
||||
|
||||
_lastUseSourceId = 0u;
|
||||
_lastUseTargetId = 0u;
|
||||
_awaitingAppraisalId = 0u;
|
||||
_currentAppraisalId = 0u;
|
||||
_outbound.Clear();
|
||||
_pendingPickup = null;
|
||||
_pendingUse = null;
|
||||
_lastUseMs = long.MinValue / 2;
|
||||
_clearEpoch++;
|
||||
if (resetInventory)
|
||||
|
|
|
|||
|
|
@ -224,15 +224,47 @@ public sealed class VendorShopItemMaterializer : IDisposable
|
|||
/// <summary>
|
||||
/// Field mapping from the domain-shaped <see cref="VendorShopItem"/> to
|
||||
/// the wire-shaped merge patch <see cref="ClientObjectTable.Ingest"/>
|
||||
/// expects. <see cref="VendorShopItem.DescStackSize"/> — not
|
||||
/// <see cref="VendorShopItem.StackSize"/>, ItemProfile's separate packed
|
||||
/// SUPPLY-count field — is the wire equivalent of an ordinary
|
||||
/// CreateObject's own StackSize field (see the doc comment on
|
||||
/// <see cref="VendorShopItem.DescStackSize"/>). Every field
|
||||
/// <see cref="VendorShopItem"/> doesn't carry (capacity, equip mask,
|
||||
/// combat use, etc.) is passed null, leaving it untouched on a refresh
|
||||
/// and defaulted on a fresh object per <see cref="WeenieData"/>'s
|
||||
/// null-preserving merge contract.
|
||||
/// expects.
|
||||
/// <para>
|
||||
/// G2 gate-finding fix (2026-08-08, register AP-169): retail's own
|
||||
/// client (<c>gmToolbarUI::HandleSelectionChanged</c>, <c>pc:198688</c>/
|
||||
/// <c>198744</c>/<c>198774</c>/<c>198791</c>) reads
|
||||
/// <c>eax_5->pwd._stackSize</c> — our <see cref="VendorShopItem.DescStackSize"/>
|
||||
/// — uniformly for BOTH owned-inventory and vendor-owned selections to
|
||||
/// decide whether the toolbar split slider shows and what it caps at.
|
||||
/// The user's local ACE server never carries that value for a browse-
|
||||
/// list row: <c>Vendor.LoadInventoryItem</c>
|
||||
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:144-172</c>)
|
||||
/// builds the listing <c>WorldObject</c> via
|
||||
/// <c>WorldObjectFactory.CreateNewWorldObject</c> and sets ONLY
|
||||
/// <c>wo.VendorShopCreateListStackSize</c> (our
|
||||
/// <see cref="VendorShopItem.StackSize"/>, the packed "how many for
|
||||
/// sale" ItemProfile dword) — it never calls <c>wo.SetStackSize(...)</c>,
|
||||
/// so <c>PublicWeenieDesc</c>'s own conditional <c>StackSize</c> field
|
||||
/// (walked by <c>GameEventApproachVendor.cs:60</c>'s
|
||||
/// <c>obj.SerializeGameDataOnly</c>) comes back null on the real wire
|
||||
/// for every vendor listing. Reading only <c>DescStackSize</c> here
|
||||
/// therefore left <see cref="ClientObject.StackSize"/> at its default
|
||||
/// for every materialized shop item, so the toolbar split slider never
|
||||
/// appeared for ANY vendor stack, matching the live report exactly (it
|
||||
/// DID appear for owned-inventory stacks, which ACE populates normally
|
||||
/// via ordinary pickup/loot <c>SetStackSize</c> calls). This now prefers
|
||||
/// <c>DescStackSize</c> when present (retail-faithful first, and
|
||||
/// forward-compatible with any server that DOES populate it), falling
|
||||
/// back to the packed <c>StackSize</c> supply-count field clamped to a
|
||||
/// sane positive bound — the field that IS reliably populated against
|
||||
/// ACE. The <c>StackSize == -1</c> (unlimited-supply) sentinel has no
|
||||
/// bounded per-row purchase cap in <see cref="VendorShopItem"/>'s wire
|
||||
/// shape today (no <c>_maxStackSize</c> field carried), so it falls
|
||||
/// through to the conservative "1" default rather than inventing an
|
||||
/// arbitrary ceiling — see the register.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every other field <see cref="VendorShopItem"/> doesn't carry
|
||||
/// (capacity, equip mask, combat use, etc.) is passed null, leaving it
|
||||
/// untouched on a refresh and defaulted on a fresh object per
|
||||
/// <see cref="WeenieData"/>'s null-preserving merge contract.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static WeenieData ToWeenieData(VendorShopItem item, uint vendorId) => new(
|
||||
Guid: item.ItemGuid,
|
||||
|
|
@ -244,7 +276,7 @@ public sealed class VendorShopItemMaterializer : IDisposable
|
|||
IconUnderlayId: item.IconUnderlayId,
|
||||
Effects: item.Effects,
|
||||
Value: item.Value,
|
||||
StackSize: item.DescStackSize,
|
||||
StackSize: ResolveDisplayStackSize(item),
|
||||
StackSizeMax: null,
|
||||
Burden: null,
|
||||
ContainerId: vendorId,
|
||||
|
|
@ -259,6 +291,23 @@ public sealed class VendorShopItemMaterializer : IDisposable
|
|||
Workmanship: null,
|
||||
PluralName: item.PluralName);
|
||||
|
||||
/// <summary>
|
||||
/// G2 fix: <see cref="VendorShopItem.DescStackSize"/> when the wire
|
||||
/// actually carried it (nonzero — a genuinely retail-faithful server),
|
||||
/// else the packed <see cref="VendorShopItem.StackSize"/> supply count
|
||||
/// (what ACE reliably sends) when it names a real bounded quantity,
|
||||
/// else 1 (non-splittable — the safe default for the unlimited-supply
|
||||
/// sentinel or a genuinely single-unit listing).
|
||||
/// </summary>
|
||||
private static int? ResolveDisplayStackSize(VendorShopItem item)
|
||||
{
|
||||
if (item.DescStackSize is { } desc && desc > 0)
|
||||
return desc;
|
||||
if (item.StackSize > 0)
|
||||
return item.StackSize;
|
||||
return 1;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
|
|
|||
|
|
@ -457,17 +457,25 @@ public sealed class SelectionInteractionControllerTests
|
|||
|
||||
/// <summary>
|
||||
/// C1 (Slice 6b move-to-use, docs/research/2026-08-08-slice6b-vendor-
|
||||
/// completion-research.md Q2): an out-of-range Use kicks off the SAME
|
||||
/// local client-predicted MoveToObject approach Pickup's far-range
|
||||
/// branch already installs, giving the walk immediate visual feel. The
|
||||
/// wire send is never gated on arrival — retail's
|
||||
/// <c>ItemHolder::UseObject @ 0x00588A80</c> has no range check and
|
||||
/// sends unconditionally, so the dispatch and the approach both happen
|
||||
/// at click time, in that order. A later natural MoveTo completion must
|
||||
/// not re-dispatch (Use has no post-arrival token the way Pickup does).
|
||||
/// completion-research.md Q2, REVISED for G3 — grand-gate finding
|
||||
/// 2026-08-08, register AP-170): an out-of-range Use kicks off the SAME
|
||||
/// local client-predicted MoveToObject approach Pickup's close-range
|
||||
/// branch already arms on, giving the walk immediate visual feel. Unlike
|
||||
/// the original (send-immediately) design, the wire send is now GATED on
|
||||
/// natural arrival — live testing against the user's local ACE server
|
||||
/// showed retail's own "no range check, send immediately" assumption
|
||||
/// does not hold there: ACE's <c>Player.HandleActionUseItem</c> polls
|
||||
/// for the player to actually reach use range before calling
|
||||
/// <c>ActOnUse</c>, and a Use that arrives too early is silently lost
|
||||
/// (the vendor's cosmetic greeting fires — a distance-only reaction
|
||||
/// independent of Use — but <c>ApproachVendor</c> never comes). Nothing
|
||||
/// dispatches until <see cref="SelectionInteractionController.OnNaturalMoveToComplete"/>
|
||||
/// fires; a SECOND natural-completion call must not re-dispatch (the
|
||||
/// pending-use token is consumed on first resolve, exactly like Pickup's
|
||||
/// pending-pickup token).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FarUseApproachesThenDispatchesImmediatelyAndDoesNotRetryOnArrival()
|
||||
public void FarUseApproachesThenDispatchesOnNaturalArrival()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SetApproach(closeRange: false);
|
||||
|
|
@ -475,6 +483,11 @@ public sealed class SelectionInteractionControllerTests
|
|||
h.Controller.SendUse(Target);
|
||||
|
||||
PlayerInteractionMovementSinkAssertSingleApproach(h, Target);
|
||||
// Armed, not yet sent — the whole point of the fix.
|
||||
Assert.Empty(h.Transport.Uses);
|
||||
|
||||
h.Controller.OnNaturalMoveToComplete();
|
||||
|
||||
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||
|
||||
h.Controller.OnNaturalMoveToComplete();
|
||||
|
|
@ -483,10 +496,10 @@ public sealed class SelectionInteractionControllerTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// C1 cancellation coverage: a second far Use command (the player picked
|
||||
/// a new target, i.e. "moved on") supersedes the first local approach
|
||||
/// cleanly — no exception, no missing/duplicated dispatch, no leaked
|
||||
/// pending-pickup state (Use never arms one).
|
||||
/// C1/G3 cancellation coverage: a second far Use command (the player
|
||||
/// picked a new target, i.e. "moved on") supersedes the first local
|
||||
/// approach cleanly — the FIRST target's armed Use is cancelled (never
|
||||
/// sent), and only the SECOND dispatches, on ITS OWN natural arrival.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NewFarUseCommandSupersedesThePreviousApproachCleanly()
|
||||
|
|
@ -509,23 +522,25 @@ public sealed class SelectionInteractionControllerTests
|
|||
Assert.Equal(2, h.Movement.Approaches.Count);
|
||||
Assert.Equal(Target, h.Movement.Approaches[0].Target.ServerGuid);
|
||||
Assert.Equal(otherTarget, h.Movement.Approaches[1].Target.ServerGuid);
|
||||
Assert.Equal(new[] { Target, otherTarget }, h.Transport.Uses);
|
||||
// Neither has sent yet — both are armed/superseded, not dispatched.
|
||||
Assert.Empty(h.Transport.Uses);
|
||||
|
||||
h.Controller.OnNaturalMoveToComplete();
|
||||
|
||||
Assert.Equal(new[] { Target, otherTarget }, h.Transport.Uses);
|
||||
// Only the surviving (second) approach's Use goes out.
|
||||
Assert.Equal(new[] { otherTarget }, h.Transport.Uses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C1 cancellation coverage: the underlying MoveTo controller cancelling
|
||||
/// out from under a far Use's local approach (player moved away with
|
||||
/// WASD, or any other source of <see cref="WeenieError"/>) must not
|
||||
/// retract or duplicate the Use, which already went out unconditionally
|
||||
/// at click time — Use holds no pending-pickup state for
|
||||
/// <c>OnMoveToCancelled</c> to touch.
|
||||
/// C1/G3 cancellation coverage: the underlying MoveTo controller
|
||||
/// cancelling out from under a far Use's local approach (player moved
|
||||
/// away with WASD, or any other source of <see cref="WeenieError"/>)
|
||||
/// must cancel the ARMED (not-yet-sent) Use — retail's own server-side
|
||||
/// poll would never have seen the player arrive either, so nothing
|
||||
/// should reach the wire.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MovingAwayDuringAFarUseApproachDoesNotAffectTheAlreadyDispatchedUse()
|
||||
public void MovingAwayDuringAFarUseApproachCancelsTheArmedUse()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SetApproach(closeRange: false);
|
||||
|
|
@ -534,7 +549,7 @@ public sealed class SelectionInteractionControllerTests
|
|||
h.Controller.OnMoveToCancelled(WeenieError.ActionCancelled);
|
||||
h.Controller.OnNaturalMoveToComplete();
|
||||
|
||||
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||
Assert.Empty(h.Transport.Uses);
|
||||
}
|
||||
|
||||
private static void PlayerInteractionMovementSinkAssertSingleApproach(
|
||||
|
|
|
|||
|
|
@ -673,6 +673,102 @@ public class SelectedObjectControllerTests
|
|||
// fails, and the failure is what gets fixed).
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// G2 (grand-gate finding): C4 passed by manually hand-setting
|
||||
/// <c>ClientObject.StackSize</c> directly, bypassing BOTH the real
|
||||
/// <see cref="VendorShopItemMaterializer"/> that populates it from a
|
||||
/// live <c>ApproachVendor</c> snapshot AND the real
|
||||
/// <c>ObjectUpdated</c>/<c>ObjectAdded</c> event wiring. This test drives
|
||||
/// the same scenario through the REAL materializer — <see cref="VendorState.Apply"/>
|
||||
/// fires <see cref="VendorState.Changed"/>, the REAL
|
||||
/// <see cref="VendorShopItemMaterializer"/> (subscribed exactly like
|
||||
/// production's <c>RuntimeInventoryState</c> constructor) ingests the
|
||||
/// shop item into the SAME <see cref="ClientObjectTable"/>, and only
|
||||
/// THEN is the item selected — closing the gap the C4 harness left open.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void G2_VendorStackSelection_ThroughRealMaterializer_ShowsSplitSlider()
|
||||
{
|
||||
const uint vendorGuid = 0x70000011u;
|
||||
const uint arrowsGuid = 0x60009011u;
|
||||
|
||||
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
||||
var objects = new ClientObjectTable();
|
||||
var vendor = new VendorState();
|
||||
var selection = new SelectionState();
|
||||
var splitQuantity = new StackSplitQuantityState();
|
||||
|
||||
// REAL materializer, wired exactly like RuntimeInventoryState's
|
||||
// constructor (RuntimeInventoryState.cs:74):
|
||||
// VendorItems = new VendorShopItemMaterializer(Vendor, _entityObjects.Objects);
|
||||
using var materializer = new VendorShopItemMaterializer(vendor, objects);
|
||||
|
||||
SelectedObjectController controller = SelectedObjectController.Bind(
|
||||
layout,
|
||||
selection,
|
||||
subscribeHealthChanged: _ => { },
|
||||
unsubscribeHealthChanged: _ => { },
|
||||
subscribeItemManaChanged: _ => { },
|
||||
unsubscribeItemManaChanged: _ => { },
|
||||
isHealthTarget: _ => false,
|
||||
isOwnedByPlayer: _ => false,
|
||||
name: guid => objects.Get(guid)?.GetAppropriateName(),
|
||||
healthPercent: _ => 0f,
|
||||
hasHealth: _ => false,
|
||||
stackSize: guid => (uint)(objects.Get(guid)?.StackSize ?? 0),
|
||||
sendQueryHealth: _ => { },
|
||||
manaPercent: _ => 0f,
|
||||
sendQueryItemMana: _ => { },
|
||||
datFont: null,
|
||||
splitQuantity: splitQuantity,
|
||||
// REAL subscription this time (C4 used no-op lambdas here).
|
||||
subscribeObjectUpdated: h => objects.ObjectUpdated += h,
|
||||
unsubscribeObjectUpdated: h => objects.ObjectUpdated -= h,
|
||||
isVendorSplitExempt: guid =>
|
||||
vendor.VendorId != 0u
|
||||
&& objects.Get(guid) is { } vendorCandidate
|
||||
&& vendorCandidate.ContainerId == vendor.VendorId
|
||||
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type));
|
||||
|
||||
// G2 root cause: a REAL ACE vendor listing, byte-for-byte. ACE's
|
||||
// Vendor.LoadInventoryItem (Vendor.cs:144-172) builds the browse-list
|
||||
// WorldObject via WorldObjectFactory.CreateNewWorldObject and sets
|
||||
// ONLY wo.VendorShopCreateListStackSize = stackSize ?? -1 (the "how
|
||||
// many available" packed dword — our VendorShopItem.StackSize) —
|
||||
// it NEVER calls wo.SetStackSize(...), so the per-item
|
||||
// PublicWeenieDesc's own conditional StackSize field (our
|
||||
// DescStackSize — retail's pwd._stackSize, what
|
||||
// GameEventApproachVendor.cs:60's SerializeGameDataOnly walks) comes
|
||||
// back null on the real wire. StackSize=100 (packed "100 for sale"),
|
||||
// DescStackSize=null (ACE never sets it) is what a real vendor
|
||||
// listing looks like, NOT the DescStackSize=100 shape used above.
|
||||
vendor.Apply(
|
||||
vendorGuid,
|
||||
new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""),
|
||||
new[]
|
||||
{
|
||||
new VendorShopItem(
|
||||
arrowsGuid, StackSize: 100, WeenieClassId: 5u, Name: "Arrow",
|
||||
ItemType: (uint)ItemType.MissileWeapon, IconId: 200u, Value: 100,
|
||||
DescStackSize: null, PluralName: "Arrows"),
|
||||
});
|
||||
|
||||
// The item is now materialized (ClientObjectTable.Ingest ran inside
|
||||
// VendorState.Apply's Changed dispatch) BEFORE selection, exactly
|
||||
// like a real click on VendorUiController's item list.
|
||||
Assert.NotNull(objects.Get(arrowsGuid));
|
||||
Assert.Equal(100, objects.Get(arrowsGuid)!.StackSize);
|
||||
|
||||
selection.Select(arrowsGuid, SelectionChangeSource.Vendor);
|
||||
|
||||
var slider = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(SelectedObjectController.StackSizeSliderId));
|
||||
Assert.True(slider.Visible);
|
||||
Assert.Equal(100u, splitQuantity.Maximum);
|
||||
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void C4_VendorOwnedSplitExemptStackSelection_MatchesRetailsToolbarPresentation()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1636,6 +1636,98 @@ public sealed class VendorUiControllerTests
|
|||
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G1 (vendor gate finding): live testing showed "Buy All" false-blocking
|
||||
/// a container purchase while the player visibly had free container
|
||||
/// slots ("3 left"). Root cause: <c>CountPlayerContents</c> classified
|
||||
/// "is this occupied slot a container" from the item's own
|
||||
/// <see cref="ItemType.Container"/> bit OR nonzero
|
||||
/// <c>ItemsCapacity</c>/<c>ContainersCapacity</c> — a LOCAL heuristic —
|
||||
/// instead of retail's actual wire-carried classification
|
||||
/// (<c>ContainerProperties</c>, threaded onto
|
||||
/// <see cref="ClientObject.ContainerTypeHint"/> by every membership
|
||||
/// path). A non-Container-typed object with a stray nonzero capacity
|
||||
/// field (and a wire hint of <c>None</c>) was over-counted as an
|
||||
/// occupied CONTAINER slot, shrinking the free-slot count below the
|
||||
/// real one and false-blocking a purchase the player had room for.
|
||||
/// This test pins a player pack with one such object (armor, hint=None,
|
||||
/// but ItemsCapacity happens to read nonzero) plus 6 free container
|
||||
/// slots out of 7 — buying ONE more container must succeed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuyAllButton_StrayCapacityFieldOnNonContainerItem_DoesNotFalseBlockWithFreeSlots()
|
||||
{
|
||||
var h = new Harness();
|
||||
// AddOrUpdate FIRST (creates the object carrying the stray capacity
|
||||
// field), InitializeInventoryManifest SECOND (updates the SAME
|
||||
// object's placement/hint in place — it does not touch Type/
|
||||
// ItemsCapacity, matching AddOrUpdate's own doc comment: "does NOT
|
||||
// update the container index").
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x60002001u,
|
||||
Type = ItemType.Armor,
|
||||
ItemsCapacity = 3,
|
||||
});
|
||||
h.Objects.InitializeInventoryManifest(Harness.PlayerGuid, new[]
|
||||
{
|
||||
// Wire truth (ContainerType=0/None): this is NOT a container.
|
||||
// Its Type is Armor (not Container) but it carries a stray
|
||||
// nonzero ItemsCapacity — the old heuristic misread that as
|
||||
// "occupies a container slot."
|
||||
new ContainerContentEntry(0x60002001u, 0u),
|
||||
});
|
||||
// Only 1 container slot total so a miscount of this ONE stray item
|
||||
// as a container (containersUsed 1 instead of 0) actually flips the
|
||||
// guard, instead of being absorbed by the harness's 7-slot default.
|
||||
h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 1;
|
||||
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100),
|
||||
});
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
||||
h.BuyAllButton.OnClick!.Invoke();
|
||||
|
||||
// 1 capacity - 0 REAL containers used = 1 free -> buying 1 succeeds.
|
||||
Assert.Single(h.BuyAlls);
|
||||
Assert.Empty(h.SystemMessages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G1 companion: a REAL side-pack (wire hint ContainerType=1/Container,
|
||||
/// no ItemType.Container bit and no capacity fields populated — e.g. a
|
||||
/// container object seen only via a membership manifest, not its own
|
||||
/// full CreateObject) still correctly consumes a container slot. Proves
|
||||
/// the fix's hint-primary classification isn't just permissive by
|
||||
/// omission — it still catches a real container the OLD Type-bit-only
|
||||
/// fallback would have missed too.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuyAllButton_HintOnlyContainer_StillCountsAgainstContainerCapacity()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Objects.InitializeInventoryManifest(Harness.PlayerGuid, new[]
|
||||
{
|
||||
new ContainerContentEntry(0x60002010u, 1u), // Container, hint-only
|
||||
});
|
||||
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100),
|
||||
});
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 1;
|
||||
|
||||
h.BuyAllButton.OnClick!.Invoke();
|
||||
|
||||
// Capacity 1, 1 REAL container already used (via hint) -> 0 free,
|
||||
// buying 1 more must block.
|
||||
Assert.Empty(h.BuyAlls);
|
||||
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Linq;
|
||||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -765,6 +766,208 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal(new[] { item }, table.GetContents(pack));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SCRATCH G4 reproduction: a vendor BUY's real wire shape is a fresh
|
||||
/// CreateObject (a BRAND NEW guid our client has never seen before —
|
||||
/// ACE's Player_Commerce.cs FinalizeBuyTransaction/ItemProfileToWorldObjects
|
||||
/// mints a new WorldObject for common stock, distinct from the shop-list
|
||||
/// guid) carrying ContainerId=player already (TryAddToInventory sets it
|
||||
/// before TryCreateInInventoryWithNetworking sends CreateObject), followed
|
||||
/// by the SAME InventoryPutObjInContainer (0x0022) placement=0 message a
|
||||
/// pickup gets. This drives that EXACT two-message sequence — Ingest
|
||||
/// (simulating CreateObject) THEN ApplyConfirmedServerMove (simulating
|
||||
/// the 0x0022 echo) — against a pack that already has other items, to see
|
||||
/// whether the fresh-guid case actually prepends the way
|
||||
/// AuthoritativePickup_PlacementZeroInsertsAtRetailListHead's
|
||||
/// already-known-guid case (built via ReplaceContents, which creates the
|
||||
/// object FIRST) already proves.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SCRATCH_BuyShapedFreshGuidCreateObjectThenContainId_FinalOrder()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint pack = 0x50000001u;
|
||||
const uint existingA = 0xA01u;
|
||||
const uint existingB = 0xA02u;
|
||||
const uint boughtItem = 0xB01u;
|
||||
|
||||
table.InitializeInventoryManifest(pack, new[]
|
||||
{
|
||||
new ContainerContentEntry(existingA, 0u),
|
||||
new ContainerContentEntry(existingB, 0u),
|
||||
});
|
||||
|
||||
// Simulates the buy's CreateObject: a guid NEVER SEEN BEFORE,
|
||||
// already carrying ContainerId=pack (matching TryAddToInventory's
|
||||
// pre-send assignment), routed through the SAME ApplyEntitySpawn ->
|
||||
// Ingest path production CreateObject handling uses.
|
||||
table.Ingest(new WeenieData(
|
||||
Guid: boughtItem,
|
||||
Name: "Arrow",
|
||||
Type: ItemType.MissileWeapon,
|
||||
WeenieClassId: 5u,
|
||||
IconId: 0u,
|
||||
IconOverlayId: 0u,
|
||||
IconUnderlayId: 0u,
|
||||
Effects: 0u,
|
||||
Value: 100,
|
||||
StackSize: 100,
|
||||
StackSizeMax: null,
|
||||
Burden: 1,
|
||||
ContainerId: pack,
|
||||
WielderId: 0u,
|
||||
ValidLocations: null,
|
||||
CurrentWieldedLocation: null,
|
||||
Priority: null,
|
||||
ItemsCapacity: null,
|
||||
ContainersCapacity: null,
|
||||
Structure: null,
|
||||
MaxStructure: null,
|
||||
Workmanship: null));
|
||||
|
||||
// Simulates the SAME wire echo a pickup gets (0x0022,
|
||||
// InventoryPutObjInContainer / Item_ServerSaysContainId), which ACE
|
||||
// sends unconditionally after TryAddToInventory for a buy exactly
|
||||
// the same way it does for a pickup — with Placement=0.
|
||||
Assert.True(table.ApplyConfirmedServerMove(
|
||||
boughtItem,
|
||||
pack,
|
||||
newWielderId: 0u,
|
||||
newSlot: 0,
|
||||
containerTypeHint: 0u));
|
||||
|
||||
Assert.Equal(
|
||||
new[] { boughtItem, existingA, existingB },
|
||||
table.GetContents(pack));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G4 (grand-gate finding): ACE's <c>GameMessageCreateObject</c> rides
|
||||
/// <c>GameMessageGroup.SmartboxQueue</c>
|
||||
/// (references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageCreateObject.cs:8)
|
||||
/// while <c>GameEventItemServerSaysContainId</c> (our
|
||||
/// InventoryPutObjInContainer, 0x0022) rides
|
||||
/// <c>GameMessageGroup.UIQueue</c> — TWO independent reliable queues
|
||||
/// with no cross-queue ordering guarantee. A vendor buy of common stock
|
||||
/// mints a brand-new guid (ACE's <c>ItemProfileToWorldObjects</c>) the
|
||||
/// client has never seen, so unlike an ordinary pickup (whose item was
|
||||
/// already visible/known), its final resting slot depends entirely on
|
||||
/// which queue's message the client happens to process first. This
|
||||
/// drives the 0x0022 echo BEFORE its item's CreateObject — root cause,
|
||||
/// live-verified: before the fix this returned <c>applied=false</c> and
|
||||
/// silently dropped the placement, leaving the item appended at the
|
||||
/// list tail once its later CreateObject-only <c>Ingest</c> ran.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContainIdArrivingBeforeCreateObject_StillInsertsAtRetailListHead()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint pack = 0x50000001u;
|
||||
const uint existingA = 0xA01u;
|
||||
const uint existingB = 0xA02u;
|
||||
const uint boughtItem = 0xB02u;
|
||||
|
||||
table.InitializeInventoryManifest(pack, new[]
|
||||
{
|
||||
new ContainerContentEntry(existingA, 0u),
|
||||
new ContainerContentEntry(existingB, 0u),
|
||||
});
|
||||
|
||||
// The 0x0022 echo arrives FIRST — boughtItem does not exist in the
|
||||
// table yet. ApplyConfirmedServerMove itself still reports failure
|
||||
// (nothing to move YET) — the fix is that it no longer drops the
|
||||
// request on the floor.
|
||||
Assert.False(table.ApplyConfirmedServerMove(
|
||||
boughtItem,
|
||||
pack,
|
||||
newWielderId: 0u,
|
||||
newSlot: 0,
|
||||
containerTypeHint: 0u));
|
||||
|
||||
// THEN the CreateObject arrives.
|
||||
table.Ingest(new WeenieData(
|
||||
Guid: boughtItem,
|
||||
Name: "Arrow",
|
||||
Type: ItemType.MissileWeapon,
|
||||
WeenieClassId: 5u,
|
||||
IconId: 0u,
|
||||
IconOverlayId: 0u,
|
||||
IconUnderlayId: 0u,
|
||||
Effects: 0u,
|
||||
Value: 100,
|
||||
StackSize: 100,
|
||||
StackSizeMax: null,
|
||||
Burden: 1,
|
||||
ContainerId: pack,
|
||||
WielderId: 0u,
|
||||
ValidLocations: null,
|
||||
CurrentWieldedLocation: null,
|
||||
Priority: null,
|
||||
ItemsCapacity: null,
|
||||
ContainersCapacity: null,
|
||||
Structure: null,
|
||||
MaxStructure: null,
|
||||
Workmanship: null));
|
||||
|
||||
// The stashed placement replays: the bought item lands at the head
|
||||
// (retail slot 0), matching the in-order case exactly.
|
||||
Assert.Equal(
|
||||
new[] { boughtItem, existingA, existingB },
|
||||
table.GetContents(pack));
|
||||
Assert.Equal(pack, table.Get(boughtItem)!.ContainerId);
|
||||
Assert.Equal(0u, table.Get(boughtItem)!.ContainerTypeHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G4 companion: a stashed placement for a guid that never actually
|
||||
/// arrives (e.g. a stale/unrelated echo) must not leak forever, or
|
||||
/// silently resurrect a placement for some LATER, unrelated recycled
|
||||
/// guid. <see cref="ClientObjectTable.Clear"/> (session teardown) drops
|
||||
/// it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PendingUnresolvedPlacement_IsDroppedByClear()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint pack = 0x50000001u;
|
||||
const uint neverArrives = 0xB03u;
|
||||
|
||||
Assert.False(table.ApplyConfirmedServerMove(
|
||||
neverArrives, pack, newWielderId: 0u, newSlot: 0, containerTypeHint: 0u));
|
||||
|
||||
table.Clear();
|
||||
|
||||
// A later, unrelated Ingest of the SAME (recycled) guid must not
|
||||
// resurrect the old stashed placement — it should append normally
|
||||
// (no other contents to reposition ahead of, so this just proves no
|
||||
// exception/misplacement occurs after a session boundary).
|
||||
table.Ingest(new WeenieData(
|
||||
Guid: neverArrives,
|
||||
Name: "Something Else",
|
||||
Type: ItemType.Misc,
|
||||
WeenieClassId: 9u,
|
||||
IconId: 0u,
|
||||
IconOverlayId: 0u,
|
||||
IconUnderlayId: 0u,
|
||||
Effects: 0u,
|
||||
Value: 1,
|
||||
StackSize: null,
|
||||
StackSizeMax: null,
|
||||
Burden: 1,
|
||||
ContainerId: pack,
|
||||
WielderId: 0u,
|
||||
ValidLocations: null,
|
||||
CurrentWieldedLocation: null,
|
||||
Priority: null,
|
||||
ItemsCapacity: null,
|
||||
ContainersCapacity: null,
|
||||
Structure: null,
|
||||
MaxStructure: null,
|
||||
Workmanship: null));
|
||||
|
||||
Assert.Equal(new[] { neverArrives }, table.GetContents(pack));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativePickup_PlacementZeroInsertsAtRetailListHead()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -378,6 +378,143 @@ public sealed class RuntimeInteractionTransactionStateTests
|
|||
state.Dispose();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// G3 (grand-gate finding) — arrival-gated Use, mirroring the pickup
|
||||
// coverage above.
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void PostArrivalUseRequiresItsExactApproachTokenAndDispatchesTheHeldReservation()
|
||||
{
|
||||
using var inventory = NewInventory(out _);
|
||||
using var state = new RuntimeInteractionTransactionState(inventory);
|
||||
ItemUseRequestReservation reservation = state.BeginUseRequestReservation();
|
||||
var approach = new RuntimeInteractionApproachToken(3u, 7u);
|
||||
|
||||
Assert.True(state.TryArmPostArrivalUse(
|
||||
Item,
|
||||
ownedByPlayer: false,
|
||||
useable: true,
|
||||
reservation,
|
||||
approach,
|
||||
out RuntimePendingUse pending));
|
||||
Assert.True(state.HasPendingUse);
|
||||
// Nothing sent yet — armed, not dispatched.
|
||||
Assert.Equal(1, inventory.BusyCount);
|
||||
|
||||
// The wrong token must not resolve it.
|
||||
Assert.False(state.TryResolveUseApproachCompletion(
|
||||
new RuntimeInteractionApproachToken(3u, 8u),
|
||||
natural: true,
|
||||
out _));
|
||||
Assert.True(state.HasPendingUse);
|
||||
|
||||
Assert.True(state.TryResolveUseApproachCompletion(
|
||||
approach,
|
||||
natural: true,
|
||||
out RuntimePendingUse ready));
|
||||
Assert.Equal(pending.Token, ready.Token);
|
||||
Assert.False(state.HasPendingUse);
|
||||
|
||||
var transport = new Transport();
|
||||
RuntimeInteractionDispatchResult result = state.TryDispatchUse(
|
||||
ready.ServerGuid,
|
||||
ready.OwnedByPlayer,
|
||||
ready.Useable,
|
||||
ready.Reservation,
|
||||
transport,
|
||||
out uint sequence);
|
||||
|
||||
Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result);
|
||||
Assert.Equal(1u, sequence);
|
||||
Assert.Equal(new[] { Item }, transport.Uses);
|
||||
// The reservation transferred to the authoritative UseDone wait,
|
||||
// exactly like an ordinary immediate Use.
|
||||
Assert.Equal(1, inventory.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseApproachCompletionCancellationReleasesTheHeldReservationWithoutSending()
|
||||
{
|
||||
using var inventory = NewInventory(out _);
|
||||
using var state = new RuntimeInteractionTransactionState(inventory);
|
||||
ItemUseRequestReservation reservation = state.BeginUseRequestReservation();
|
||||
var approach = new RuntimeInteractionApproachToken(1u, 1u);
|
||||
Assert.True(state.TryArmPostArrivalUse(
|
||||
Item, ownedByPlayer: false, useable: true, reservation, approach, out _));
|
||||
Assert.Equal(1, inventory.BusyCount);
|
||||
|
||||
// natural: false (cancellation, e.g. moved away) — TryResolveUseApproachCompletion
|
||||
// itself returns false; the caller is responsible for releasing the
|
||||
// reservation (mirrors SelectionInteractionController.HandleUseApproachCompletion).
|
||||
Assert.False(state.TryResolveUseApproachCompletion(
|
||||
approach, natural: false, out RuntimePendingUse cancelled));
|
||||
Assert.False(state.HasPendingUse);
|
||||
cancelled.Reservation?.CancelBeforeDispatch();
|
||||
|
||||
Assert.Equal(0, inventory.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelPendingUseByGuidOnlyMatchesTheArmedTarget()
|
||||
{
|
||||
using var inventory = NewInventory(out _);
|
||||
using var state = new RuntimeInteractionTransactionState(inventory);
|
||||
ItemUseRequestReservation reservation = state.BeginUseRequestReservation();
|
||||
Assert.True(state.TryArmPostArrivalUse(
|
||||
Item, ownedByPlayer: false, useable: true, reservation,
|
||||
new RuntimeInteractionApproachToken(1u, 1u), out _));
|
||||
|
||||
Assert.False(state.TryCancelPendingUse(Container, out _));
|
||||
Assert.True(state.HasPendingUse);
|
||||
|
||||
Assert.True(state.TryCancelPendingUse(Item, out RuntimePendingUse cancelled));
|
||||
Assert.False(state.HasPendingUse);
|
||||
cancelled.Reservation?.CancelBeforeDispatch();
|
||||
Assert.Equal(0, inventory.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArmingASecondUseWhileOneIsAlreadyArmedFails()
|
||||
{
|
||||
using var inventory = NewInventory(out _);
|
||||
using var state = new RuntimeInteractionTransactionState(inventory);
|
||||
Assert.True(state.TryArmPostArrivalUse(
|
||||
Item, ownedByPlayer: false, useable: true, reservation: null,
|
||||
new RuntimeInteractionApproachToken(1u, 1u), out _));
|
||||
|
||||
Assert.False(state.TryArmPostArrivalUse(
|
||||
Container, ownedByPlayer: false, useable: true, reservation: null,
|
||||
new RuntimeInteractionApproachToken(2u, 2u), out _));
|
||||
Assert.Equal(Item, state.TryGetPendingUse(out RuntimePendingUse stillArmed)
|
||||
? stillArmed.ServerGuid
|
||||
: 0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingUseReservationIsReleasedByResetSessionAndDisposal()
|
||||
{
|
||||
using var inventory = NewInventory(out _);
|
||||
var state = new RuntimeInteractionTransactionState(inventory);
|
||||
ItemUseRequestReservation reservation = state.BeginUseRequestReservation();
|
||||
Assert.True(state.TryArmPostArrivalUse(
|
||||
Item, ownedByPlayer: false, useable: true, reservation,
|
||||
new RuntimeInteractionApproachToken(1u, 1u), out _));
|
||||
Assert.Equal(1, inventory.BusyCount);
|
||||
|
||||
state.ResetSession();
|
||||
|
||||
// G3: ResetCore releases a live pending-use reservation even when no
|
||||
// caller explicitly cancelled it first — must not leak a busy-count
|
||||
// reference.
|
||||
Assert.Equal(0, inventory.BusyCount);
|
||||
Assert.False(state.HasPendingUse);
|
||||
|
||||
state.Dispose();
|
||||
Assert.True(state.CaptureOwnership().IsConverged);
|
||||
state.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstancesDoNotShareThrottleQueueAppraisalOrPickupState()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -116,6 +116,58 @@ public sealed class VendorShopItemMaterializerTests
|
|||
Assert.Equal(5, objects.Get(ItemA)!.StackSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G2 (grand-gate finding): a REAL ACE vendor listing carries
|
||||
/// <c>DescStackSize=null</c> (ACE's <c>Vendor.LoadInventoryItem</c> never
|
||||
/// calls <c>wo.SetStackSize</c> on the browse-list WorldObject) and only
|
||||
/// the packed <see cref="VendorShopItem.StackSize"/> supply-count field
|
||||
/// (<c>VendorShopCreateListStackSize</c>) names a real quantity. Before
|
||||
/// the fix, <c>ToWeenieData</c> read only <c>DescStackSize</c>, so
|
||||
/// <see cref="ClientObject.StackSize"/> came back 1 for every vendor
|
||||
/// item — the toolbar split slider (which gates on
|
||||
/// <c>stackSize > 1</c>) never appeared for ANY vendor stack. This
|
||||
/// pins the fallback: no <c>DescStackSize</c>, packed
|
||||
/// <c>StackSize=100</c> -> <c>ClientObject.StackSize</c> resolves to
|
||||
/// 100, not 1.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Apply_NoDescStackSize_FallsBackToPackedSupplyCount()
|
||||
{
|
||||
var vendor = new VendorState();
|
||||
var objects = new ClientObjectTable();
|
||||
using var materializer = new VendorShopItemMaterializer(vendor, objects);
|
||||
|
||||
vendor.Apply(VendorGuid, default, new[]
|
||||
{
|
||||
new VendorShopItem(
|
||||
ItemA, StackSize: 100, WeenieClassId: 1u, Name: "Arrow",
|
||||
ItemType: (uint)ItemType.MissileWeapon, IconId: 0x1234u, Value: 100,
|
||||
DescStackSize: null),
|
||||
});
|
||||
|
||||
Assert.Equal(100, objects.Get(ItemA)!.StackSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G2 companion: the packed field's -1 "unlimited supply" sentinel has
|
||||
/// no bounded per-row purchase cap in the wire shape today, so it must
|
||||
/// fall through to the safe non-splittable default (1) rather than
|
||||
/// literally propagating -1 (which would read as a huge unsigned
|
||||
/// "stack size" to <see cref="SelectedObjectController"/>'s
|
||||
/// <c>stackSize > 1</c> gate).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Apply_UnlimitedSupplySentinel_FallsBackToNonSplittableDefault()
|
||||
{
|
||||
var vendor = new VendorState();
|
||||
var objects = new ClientObjectTable();
|
||||
using var materializer = new VendorShopItemMaterializer(vendor, objects);
|
||||
|
||||
vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Bread") }); // StackSize: -1, DescStackSize: null
|
||||
|
||||
Assert.Equal(1, objects.Get(ItemA)!.StackSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refreshed_ItemNoLongerListed_IsRemoved()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue