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:
parent
bee38b0746
commit
067cbea8a5
26 changed files with 2682 additions and 42 deletions
293
src/AcDream.App/UI/Layout/SecureTradeUiController.cs
Normal file
293
src/AcDream.App/UI/Layout/SecureTradeUiController.cs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the imported retail <c>gmSecureTradeUI</c> layout
|
||||
/// (LayoutDesc <c>0x2100000D</c>, root <c>0x1000007A</c>) to
|
||||
/// <see cref="RuntimeTradeState"/>'s view. No panel geometry is synthesized:
|
||||
/// every control is the authored element.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail references (lane A, docs/research/2026-08-14-trade-laneA-ui.md):
|
||||
/// <c>gmSecureTradeUI::PostInit @ 0x004CA160</c> binds exactly these ids;
|
||||
/// <c>ListenToElementMessage @ 0x004CAE80</c> reacts to the Trade button
|
||||
/// (0x10000086), Clear All (0x1000008A), and the close X (0x1000008B);
|
||||
/// <c>RecvNotice_RegisterTrade @ 0x004CA5C0</c> opens the panel. The accept
|
||||
/// presentation follows lane A's recommendation: driven off the owner's own
|
||||
/// accepted booleans (the partner status icon's authored 'Highlight' state;
|
||||
/// the Trade button's Selected latch), not retail's ambiguous literal
|
||||
/// m_state numbers. Open paths (retail's own two):
|
||||
/// <c>ItemHolder::DetermineUseResult @ 0x00588460</c> result 5 (Use on a
|
||||
/// player) and <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c>'s
|
||||
/// DragItemOnPlayerOpensSecureTrade branch → both surface here through
|
||||
/// <see cref="RequestSecureTrade"/> (the ItemInteractionController event),
|
||||
/// mirroring <c>ClientTradeSystem::AttemptToOpenTradeNegotiations
|
||||
/// @ 0x0056DEE0</c> / <c>AttemptToTradeItem @ 0x0056DF80</c> — the latter's
|
||||
/// "queue the dragged item until the window registers" is the pending-stage
|
||||
/// latch consumed in <see cref="Tick"/>.
|
||||
/// </remarks>
|
||||
public sealed class SecureTradeUiController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x2100000Du;
|
||||
public const uint RootId = 0x1000007Au;
|
||||
public const uint PartnerNameId = 0x1000007Eu;
|
||||
public const uint PartnerStatusId = 0x1000007Fu;
|
||||
public const uint PartnerCountId = 0x10000080u;
|
||||
public const uint PartnerListId = 0x10000081u;
|
||||
public const uint SelfNameId = 0x10000085u;
|
||||
public const uint TradeButtonId = 0x10000086u;
|
||||
public const uint SelfCountId = 0x10000087u;
|
||||
public const uint SelfListId = 0x10000088u;
|
||||
public const uint ClearAllButtonId = 0x1000008Au;
|
||||
public const uint CloseButtonId = 0x1000008Bu;
|
||||
|
||||
/// <summary>The authored partner-status accept cue (probe: element
|
||||
/// 0x1000007F states '', 'Highlight', 'Ghosted').</summary>
|
||||
private const string AcceptedState = "Highlight";
|
||||
|
||||
public sealed record Bindings(
|
||||
IRuntimeTradeView Trade,
|
||||
ClientObjectTable Objects,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
||||
Action<uint> OpenTrade,
|
||||
Action CloseTrade,
|
||||
Action<uint> AddToTrade,
|
||||
Action<bool /*selfAccepted*/, bool /*partnerAccepted*/, uint /*partner*/> AcceptTrade,
|
||||
Action DeclineTrade,
|
||||
Action ResetTrade,
|
||||
Action<bool> SetWindowVisible);
|
||||
|
||||
private readonly Bindings _bindings;
|
||||
private readonly UiText? _partnerName;
|
||||
private readonly UiElement? _partnerStatus;
|
||||
private readonly UiText? _partnerCount;
|
||||
private readonly UiItemList? _partnerList;
|
||||
private readonly UiText? _selfCount;
|
||||
private readonly UiItemList? _selfList;
|
||||
private readonly UiButton? _tradeButton;
|
||||
|
||||
private long _lastRevision = long.MinValue;
|
||||
private bool _wasOpen;
|
||||
private uint _pendingPartner;
|
||||
private uint _pendingStageItem;
|
||||
private bool _disposed;
|
||||
|
||||
private SecureTradeUiController(
|
||||
ImportedLayout layout,
|
||||
Bindings bindings)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_partnerName = layout.FindElement(PartnerNameId) as UiText;
|
||||
_partnerStatus = layout.FindElement(PartnerStatusId);
|
||||
_partnerCount = layout.FindElement(PartnerCountId) as UiText;
|
||||
_partnerList = layout.FindElement(PartnerListId) as UiItemList;
|
||||
_selfCount = layout.FindElement(SelfCountId) as UiText;
|
||||
_selfList = layout.FindElement(SelfListId) as UiItemList;
|
||||
_tradeButton = layout.FindElement(TradeButtonId) as UiButton;
|
||||
|
||||
if (_tradeButton is not null)
|
||||
{
|
||||
// Retail's accept TOGGLE: not-yet-accepted click → AcceptTrade;
|
||||
// already-accepted click → DeclineTrade (withdraw). Selected is
|
||||
// seeded from the store each Tick (the CH6a/b mirror discipline).
|
||||
_tradeButton.SuppressSelfToggle = true;
|
||||
_tradeButton.OnClick = () =>
|
||||
{
|
||||
RuntimeTradeSnapshot snapshot = _bindings.Trade.Snapshot;
|
||||
if (!snapshot.IsOpen) return;
|
||||
if (snapshot.SelfAccepted)
|
||||
_bindings.DeclineTrade();
|
||||
else
|
||||
_bindings.AcceptTrade(
|
||||
true, snapshot.PartnerAccepted, snapshot.PartnerGuid);
|
||||
};
|
||||
}
|
||||
if (layout.FindElement(ClearAllButtonId) is UiButton clearAll)
|
||||
clearAll.OnClick = () =>
|
||||
{
|
||||
if (_bindings.Trade.Snapshot.IsOpen) _bindings.ResetTrade();
|
||||
};
|
||||
if (layout.FindElement(CloseButtonId) is UiButton close)
|
||||
close.OnClick = () =>
|
||||
{
|
||||
if (_bindings.Trade.Snapshot.IsOpen) _bindings.CloseTrade();
|
||||
};
|
||||
|
||||
// Retail registers the drag handler on the SELF grid only
|
||||
// (PostInit @ 0x004CA1F7; drops land only on your own side —
|
||||
// HandleDropRelease's ancestor-chain check). An inventory item
|
||||
// dropped on the grid stages it.
|
||||
_selfList?.RegisterDragHandler(new SelfGridDropHandler(this));
|
||||
|
||||
_bindings.SetWindowVisible(false);
|
||||
}
|
||||
|
||||
public static SecureTradeUiController? Bind(
|
||||
ImportedLayout layout, Bindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
// The two grids are the panel's load-bearing controls; a layout
|
||||
// missing either cannot present a trade honestly.
|
||||
if (layout.FindElement(SelfListId) is not UiItemList
|
||||
|| layout.FindElement(PartnerListId) is not UiItemList)
|
||||
return null;
|
||||
return new SecureTradeUiController(layout, bindings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two retail open paths (Use-on-player, drag-item-on-player) —
|
||||
/// raised by ItemInteractionController. When a trade with this partner
|
||||
/// is already open, a dragged item stages immediately
|
||||
/// (<c>AttemptToTradeItem</c>'s open-trade branch); otherwise open
|
||||
/// negotiations and latch the item until RegisterTrade arrives.
|
||||
/// </summary>
|
||||
public void RequestSecureTrade(uint partnerGuid, uint itemGuid)
|
||||
{
|
||||
if (_disposed || partnerGuid == 0u) return;
|
||||
RuntimeTradeSnapshot snapshot = _bindings.Trade.Snapshot;
|
||||
if (snapshot.IsOpen && snapshot.PartnerGuid == partnerGuid)
|
||||
{
|
||||
if (itemGuid != 0u) _bindings.AddToTrade(itemGuid);
|
||||
return;
|
||||
}
|
||||
_pendingPartner = partnerGuid;
|
||||
_pendingStageItem = itemGuid;
|
||||
_bindings.OpenTrade(partnerGuid);
|
||||
}
|
||||
|
||||
/// <summary>Applies the latest owner snapshot (revision-gated).</summary>
|
||||
public void Tick()
|
||||
{
|
||||
if (_disposed) return;
|
||||
RuntimeTradeSnapshot snapshot = _bindings.Trade.Snapshot;
|
||||
|
||||
if (snapshot.IsOpen && !_wasOpen)
|
||||
{
|
||||
_wasOpen = true;
|
||||
_bindings.SetWindowVisible(true);
|
||||
// AttemptToTradeItem's queued item — stage it now that the
|
||||
// window registered, if the register matched the request.
|
||||
if (_pendingStageItem != 0u
|
||||
&& (_pendingPartner == 0u
|
||||
|| snapshot.PartnerGuid == _pendingPartner))
|
||||
{
|
||||
_bindings.AddToTrade(_pendingStageItem);
|
||||
}
|
||||
_pendingStageItem = 0u;
|
||||
_pendingPartner = 0u;
|
||||
}
|
||||
else if (!snapshot.IsOpen && _wasOpen)
|
||||
{
|
||||
_wasOpen = false;
|
||||
_bindings.SetWindowVisible(false);
|
||||
}
|
||||
|
||||
if (snapshot.Revision == _lastRevision) return;
|
||||
_lastRevision = snapshot.Revision;
|
||||
|
||||
if (_partnerName is not null)
|
||||
{
|
||||
string name = _bindings.Objects.Get(snapshot.PartnerGuid)
|
||||
?.GetAppropriateName() ?? string.Empty;
|
||||
_partnerName.LinesProvider =
|
||||
() => [new UiText.Line(name, Vector4.One)];
|
||||
}
|
||||
// Accept cues: the partner icon's authored Highlight state (the same
|
||||
// ActiveState flip the fellowship row's amber selection uses); the
|
||||
// Trade button's Selected latch for the local player's own accept.
|
||||
if (_partnerStatus is UiDatElement status)
|
||||
status.ActiveState = snapshot.PartnerAccepted ? AcceptedState : "";
|
||||
if (_tradeButton is not null)
|
||||
_tradeButton.Selected = snapshot.SelfAccepted;
|
||||
|
||||
SetCount(_selfCount, snapshot.SelfItemCount);
|
||||
SetCount(_partnerCount, snapshot.PartnerItemCount);
|
||||
Populate(_selfList, RuntimeTradeSide.Self);
|
||||
Populate(_partnerList, RuntimeTradeSide.Partner);
|
||||
}
|
||||
|
||||
public void SyncVisibility()
|
||||
{
|
||||
_wasOpen = !_bindings.Trade.Snapshot.IsOpen; // force re-evaluate
|
||||
Tick();
|
||||
}
|
||||
|
||||
public void OnShown() => Tick();
|
||||
|
||||
private void SetCount(UiText? text, int count)
|
||||
{
|
||||
if (text is null) return;
|
||||
// Numeric-only, the AD-85 numeric-fields disposition: the authored
|
||||
// ID_SecureTrade_TotalItemsLabel template's variable shape is
|
||||
// unverified, so the DATA shows without invented surrounding words.
|
||||
string line = count.ToString();
|
||||
text.LinesProvider = () => [new UiText.Line(line, Vector4.One)];
|
||||
}
|
||||
|
||||
private void Populate(UiItemList? list, RuntimeTradeSide side)
|
||||
{
|
||||
if (list is null) return;
|
||||
using (list.DeferLayout())
|
||||
{
|
||||
list.Flush();
|
||||
foreach (uint guid in _bindings.Trade.GetItems(side))
|
||||
{
|
||||
ClientObject? item = _bindings.Objects.Get(guid);
|
||||
uint icon = item is null ? 0u : _bindings.ResolveIcon(
|
||||
item.Type,
|
||||
item.IconId,
|
||||
item.IconUnderlayId,
|
||||
item.IconOverlayId,
|
||||
item.Effects);
|
||||
var cell = new UiItemSlot
|
||||
{
|
||||
SpriteResolve = list.SpriteResolve,
|
||||
SlotIndex = list.GetNumUIItems(),
|
||||
// Staged rows are not drag sources — ACE has no
|
||||
// per-item removal (only Clear All / reset).
|
||||
AllowDragSource = false,
|
||||
};
|
||||
cell.SetItem(guid, icon);
|
||||
list.AddItem(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (_tradeButton is not null) _tradeButton.OnClick = null;
|
||||
}
|
||||
|
||||
/// <summary>Drops on the SELF grid stage the dragged inventory item
|
||||
/// (retail's AcceptDragObject → AddToTrade path).</summary>
|
||||
private sealed class SelfGridDropHandler(SecureTradeUiController owner)
|
||||
: IItemListDragHandler
|
||||
{
|
||||
public void OnDragLift(
|
||||
UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||||
{
|
||||
// The trade grids are never drag SOURCES (AllowDragSource=false
|
||||
// on every staged cell) — nothing to lift.
|
||||
}
|
||||
|
||||
public ItemDragAcceptance OnDragOver(
|
||||
UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> payload.SourceKind == ItemDragSource.Inventory
|
||||
? ItemDragAcceptance.Accept
|
||||
: ItemDragAcceptance.Reject;
|
||||
|
||||
public void HandleDropRelease(
|
||||
UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
if (payload.SourceKind != ItemDragSource.Inventory) return;
|
||||
if (owner._bindings.Trade.Snapshot.IsOpen)
|
||||
owner._bindings.AddToTrade(payload.ObjId);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue