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
|
|
@ -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,12 +756,18 @@ internal sealed class SelectionInteractionController
|
|||
|
||||
private void CancelPendingApproach()
|
||||
{
|
||||
if (!_transactions.TryCancelPendingPickup(
|
||||
if (_transactions.TryCancelPendingPickup(
|
||||
out RuntimePendingPickup pending))
|
||||
return;
|
||||
CancelPickupPresentation(
|
||||
pending.ServerGuid,
|
||||
pending.PendingPlacementToken);
|
||||
{
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue