feat: secure trade with other players - wire, RuntimeTradeState, the

authored gmSecureTradeUI window, and both retail open paths

Three-lane research first (docs/research/2026-08-14-trade-lane{A,B,C}):
retail gmSecureTradeUI decode, the byte-exact ACE/decomp/holtburger
three-way wire agreement, and the acdream seam map (which found both
open paths ALREADY classified by the ported policy - OpenSecureTrade on
Use-a-player, StartSecureTrade on drag-item-onto-player with the
DragItemOnPlayerOpensSecureTrade option - dead-ending at a stub toast).

- Core.Net: TradeRequests builders (0x1F6-0x204, retail's CM_Trade
  senders byte-checked against ACE's readers; the ACE-discarded
  AcceptTrade echo carries zero-count item lists - AD-94), corrected +
  completed inbound parsers (0x1FD-0x208; the old AddToTrade parser
  missed the SIDE dword, TradeFailure missed the reason), delegate-hole
  registrars, six WorldSession sends. 10 golden-byte tests.
- Runtime: RuntimeTradeState, the third sibling J-owner (fellowship/
  allegiance shape): session-scoped, clears at generation reset (new
  stage Trade=14), staged teardown stage 11 (Identity/EntityObjects
  shift 12/13, TeardownStageCount 14 - the FA2-era per-stage-flag test
  caught the mapping exactly as designed), combined ownership ledger,
  event routing with ACE's wrong-initiator RegisterTrade landmine
  honored (partner = whichever guid is not mine). 7 conformance tests.
- App: SecureTradeUiController binds the dedicated authored LayoutDesc
  0x2100000D (root 0x1000007A - gmSecureTradeUI::PostInit's exact ids):
  partner name/status/count/grid, the authored 'Trade' accept toggle
  (accept <-> decline withdraw), 'Clear All' (ACE clears BOTH sides -
  surfaced honestly), the X close, drop-on-your-grid staging, per-mode
  accept cues (partner icon's authored Highlight state + Trade button
  Selected latch). Mounted via the vendor recipe (nine-slice chrome,
  hidden until RegisterTrade). ItemInteractionController's two policy
  arms now raise SecureTradeRequested instead of the stub toast; the
  drag path queues the dragged item until the window registers
  (ClientTradeSystem::AttemptToTradeItem @0x0056DF80's shape).

Register: AD-94 (accept-echo zero-count lists), AD-95 (numeric-only
count texts pending template verification).

Suites: App 4,990/3, Core.Net 905, Runtime 1,626 - all green. The
panel itself is user-gate acceptance (two-client connected trade), the
#372-class lesson: fixture-green alone is not acceptance for a mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 11:49:13 +02:00
parent bee38b0746
commit 067cbea8a5
26 changed files with 2682 additions and 42 deletions

View file

@ -11,9 +11,11 @@ public readonly record struct RuntimeGameplayOwnershipSnapshot(
RuntimeCommunicationOwnershipSnapshot Communication,
RuntimeActionOwnershipSnapshot Actions,
RuntimeLocalMovementOwnershipSnapshot Movement,
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners;
// secure trade (2026-08-14) is the third.
RuntimeFellowshipOwnershipSnapshot Fellowship,
RuntimeAllegianceOwnershipSnapshot Allegiance)
RuntimeAllegianceOwnershipSnapshot Allegiance,
RuntimeTradeOwnershipSnapshot Trade)
{
public bool IsConverged =>
Inventory.IsConverged
@ -22,7 +24,8 @@ public readonly record struct RuntimeGameplayOwnershipSnapshot(
&& Actions.IsConverged
&& Movement.IsConverged
&& Fellowship.IsConverged
&& Allegiance.IsConverged;
&& Allegiance.IsConverged
&& Trade.IsConverged;
}
public static class RuntimeGameplayOwnership
@ -34,7 +37,8 @@ public static class RuntimeGameplayOwnership
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
RuntimeFellowshipState fellowship,
RuntimeAllegianceState allegiance)
RuntimeAllegianceState allegiance,
RuntimeTradeState trade)
{
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(character);
@ -43,6 +47,7 @@ public static class RuntimeGameplayOwnership
ArgumentNullException.ThrowIfNull(movement);
ArgumentNullException.ThrowIfNull(fellowship);
ArgumentNullException.ThrowIfNull(allegiance);
ArgumentNullException.ThrowIfNull(trade);
return new RuntimeGameplayOwnershipSnapshot(
inventory.CaptureOwnership(),
character.CaptureOwnership(),
@ -50,6 +55,7 @@ public static class RuntimeGameplayOwnership
actions.CaptureOwnership(),
movement.CaptureOwnership(),
fellowship.CaptureOwnership(),
allegiance.CaptureOwnership());
allegiance.CaptureOwnership(),
trade.CaptureOwnership());
}
}

View file

@ -0,0 +1,307 @@
using AcDream.Core.Net.Messages;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeTradeOwnershipSnapshot(
bool IsDisposed,
bool IsOpen,
int StagedItemCount)
{
public bool IsConverged =>
IsDisposed
&& !IsOpen
&& StagedItemCount == 0;
}
/// <summary>One player's staged-item side of the trade window.</summary>
public enum RuntimeTradeSide : uint
{
Self = 1u,
Partner = 2u,
}
/// <summary>Immutable poll snapshot of the whole trade.</summary>
public readonly record struct RuntimeTradeSnapshot(
long Revision,
bool IsOpen,
uint PartnerGuid,
bool SelfAccepted,
bool PartnerAccepted,
int SelfItemCount,
int PartnerItemCount,
uint LastFailureItemGuid,
uint LastFailureReason);
public interface IRuntimeTradeView
{
RuntimeTradeSnapshot Snapshot { get; }
/// <summary>Materialized staged-item guids for one side, in stage order.</summary>
IReadOnlyList<uint> GetItems(RuntimeTradeSide side);
}
/// <summary>
/// Canonical presentation-independent owner for the secure-trade window —
/// retail's <c>ClientTradeSystem</c>/<c>Trade</c> state, session-scoped like
/// <see cref="RuntimeFellowshipState"/> (a disconnect closes the trade
/// server-side, so this clears at every generation reset).
/// </summary>
/// <remarks>
/// Wire SSOT: <c>docs/research/2026-08-14-trade-laneB-wire.md</c>; retail UI
/// truth: <c>docs/research/2026-08-14-trade-laneA-ui.md</c>. Assembled from
/// the 0x01FD0x0208 event family. ACE landmines honored here:
/// RegisterTrade's initiator/partner fields BOTH carry the non-self player's
/// guid (the true initiator is never on the wire — <c>Player_Trade.cs:80,98</c>),
/// so <see cref="ApplyRegister"/> derives the partner as "whichever guid is
/// not mine, else either"; ResetTrade clears BOTH sides' items regardless of
/// who reset. Consumers poll <see cref="View"/>'s monotonic revision — no
/// push event, the same D2 discipline as fellowship.
/// </remarks>
public sealed class RuntimeTradeState : IDisposable
{
private readonly object _gate = new();
private readonly List<uint> _selfItems = [];
private readonly List<uint> _partnerItems = [];
private bool _isOpen;
private uint _partnerGuid;
private bool _selfAccepted;
private bool _partnerAccepted;
private uint _lastFailureItemGuid;
private uint _lastFailureReason;
private long _revision;
private bool _disposed;
public RuntimeTradeState() => View = new TradeView(this);
public IRuntimeTradeView View { get; }
public bool IsDisposed
{
get { lock (_gate) return _disposed; }
}
/// <summary>
/// 0x01FD RegisterTrade — the window opens on BOTH clients
/// (<c>Handle_Trade__Recv_RegisterTrade @ 0x0056E050</c>). Clears any
/// stale staged state from a previous trade.
/// </summary>
public void ApplyRegister(GameEvents.RegisterTrade update, uint selfGuid)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_isOpen = true;
_partnerGuid = update.Initiator != selfGuid && update.Initiator != 0u
? update.Initiator
: update.Partner;
_selfItems.Clear();
_partnerItems.Clear();
_selfAccepted = false;
_partnerAccepted = false;
_lastFailureItemGuid = 0u;
_lastFailureReason = 0u;
Bump();
}
}
/// <summary>0x01FF CloseTrade — full teardown on either side's close
/// (reason recorded nowhere; retail closes the panel outright).</summary>
public void ApplyClose()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ClearLocked();
}
}
/// <summary>0x0200 AddToTrade — Side 1 = this client's own offer echo,
/// Side 2 = the partner staged an item. ACE's slot is always 0; stage
/// order is arrival order (retail's grid does the same against ACE).</summary>
public void ApplyAdd(GameEvents.AddToTrade update)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
List<uint> items = update.Side == (uint)RuntimeTradeSide.Partner
? _partnerItems
: _selfItems;
if (!items.Contains(update.ItemGuid))
items.Add(update.ItemGuid);
// Staging changes invalidate prior acceptance server-side (ACE
// re-arms via ClearTradeAcceptance; mirrored defensively here so
// a dropped 0x0208 cannot leave a stale green check).
_selfAccepted = false;
_partnerAccepted = false;
Bump();
}
}
/// <summary>0x0201 RemoveFromTrade — ACE never emits it; honored
/// defensively for the retail handler's shape
/// (<c>Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00</c>).</summary>
public void ApplyRemove(GameEvents.RemoveFromTrade update)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
bool removed = _selfItems.Remove(update.ItemGuid);
removed |= _partnerItems.Remove(update.ItemGuid);
if (removed) Bump();
}
}
/// <summary>0x0202 AcceptTrade — who accepted; compared against the
/// local player exactly like retail's dispatch @ 0x006ACDF0.</summary>
public void ApplyAccept(uint whoAccepted, uint selfGuid)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
if (whoAccepted == selfGuid) _selfAccepted = true;
else _partnerAccepted = true;
Bump();
}
}
/// <summary>0x0203 DeclineTrade — withdraws that side's acceptance.</summary>
public void ApplyDecline(uint whoDeclined, uint selfGuid)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
if (whoDeclined == selfGuid) _selfAccepted = false;
else _partnerAccepted = false;
Bump();
}
}
/// <summary>0x0205 ResetTrade — ACE clears BOTH sides' staged items on
/// either player's reset (lane B §quirks), and acceptance with them.
/// The window stays open (a completed trade auto-resets this way).</summary>
public void ApplyReset()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
_selfItems.Clear();
_partnerItems.Clear();
_selfAccepted = false;
_partnerAccepted = false;
Bump();
}
}
/// <summary>0x0207 TradeFailure — retail removes the refused item
/// locally before showing the notice
/// (<c>Handle_Trade__Recv_TradeFailure @ 0x0056D990</c>).</summary>
public void ApplyFailure(GameEvents.TradeFailure failure)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
_selfItems.Remove(failure.ItemGuid);
_partnerItems.Remove(failure.ItemGuid);
_lastFailureItemGuid = failure.ItemGuid;
_lastFailureReason = failure.Reason;
Bump();
}
}
/// <summary>0x0208 ClearTradeAcceptance — both checks come down.</summary>
public void ApplyClearAcceptance()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
_selfAccepted = false;
_partnerAccepted = false;
Bump();
}
}
/// <summary>Generation reset / session teardown — full clear.</summary>
public void Clear()
{
lock (_gate)
{
if (_disposed) return;
ClearLocked();
}
}
public RuntimeTradeOwnershipSnapshot CaptureOwnership()
{
lock (_gate)
return new RuntimeTradeOwnershipSnapshot(
_disposed,
_isOpen,
_selfItems.Count + _partnerItems.Count);
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
ClearLocked();
_disposed = true;
}
}
private void ClearLocked()
{
bool changed = _isOpen
|| _selfItems.Count != 0
|| _partnerItems.Count != 0
|| _selfAccepted
|| _partnerAccepted;
_isOpen = false;
_partnerGuid = 0u;
_selfItems.Clear();
_partnerItems.Clear();
_selfAccepted = false;
_partnerAccepted = false;
_lastFailureItemGuid = 0u;
_lastFailureReason = 0u;
if (changed) Bump();
}
private void Bump() => _revision++;
private sealed class TradeView(RuntimeTradeState owner) : IRuntimeTradeView
{
public RuntimeTradeSnapshot Snapshot
{
get
{
lock (owner._gate)
return new RuntimeTradeSnapshot(
owner._revision,
owner._isOpen,
owner._partnerGuid,
owner._selfAccepted,
owner._partnerAccepted,
owner._selfItems.Count,
owner._partnerItems.Count,
owner._lastFailureItemGuid,
owner._lastFailureReason);
}
}
public IReadOnlyList<uint> GetItems(RuntimeTradeSide side)
{
lock (owner._gate)
return side == RuntimeTradeSide.Partner
? [.. owner._partnerItems]
: [.. owner._selfItems];
}
}
}