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
|
|
@ -923,7 +923,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
AllegianceBreak: guid => late.GameRuntime.AllegianceBreak(guid),
|
||||
AllegianceKick: guid => late.GameRuntime.AllegianceKick(guid),
|
||||
AllegianceSetUpdateSubscription: on =>
|
||||
late.GameRuntime.AllegianceSetUpdateSubscription(on)),
|
||||
late.GameRuntime.AllegianceSetUpdateSubscription(on),
|
||||
Trade: d.Runtime.Trade),
|
||||
StackSplitQuantity: d.StackSplitQuantity,
|
||||
Plugins: d.UiRegistry,
|
||||
Persistence: persistence,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@ internal sealed record LiveSessionCommandBindings(
|
|||
Action<uint> RemoveFriend,
|
||||
Action ClearFriends,
|
||||
Action RequestLegacyFriends,
|
||||
// Secure trade (2026-08-14) — the CM_Trade senders.
|
||||
Action<uint> OpenTradeNegotiations,
|
||||
Action CloseTradeNegotiations,
|
||||
Action<uint> AddToTrade,
|
||||
Action<uint, bool, bool> AcceptTrade,
|
||||
Action DeclineTrade,
|
||||
Action ResetTrade,
|
||||
Action<bool, uint, string, uint> ModifyCharacterSquelch,
|
||||
Action<bool, string> ModifyAccountSquelch,
|
||||
Action<bool, uint> ModifyGlobalSquelch,
|
||||
|
|
@ -92,6 +99,17 @@ internal readonly record struct SetSingleCharacterOptionRuntimeCmd(
|
|||
internal readonly record struct SaveCharacterOptionsRuntimeCmd;
|
||||
internal readonly record struct AddFriendRuntimeCmd(string Name);
|
||||
internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId);
|
||||
|
||||
// ── Secure trade (2026-08-14) ──────────────────────────────────────────────
|
||||
internal readonly record struct OpenTradeNegotiationsRuntimeCmd(uint PartnerGuid);
|
||||
internal readonly record struct CloseTradeNegotiationsRuntimeCmd;
|
||||
internal readonly record struct AddToTradeRuntimeCmd(uint ItemGuid);
|
||||
internal readonly record struct AcceptTradeRuntimeCmd(
|
||||
uint PartnerGuid,
|
||||
bool SelfAccepted,
|
||||
bool PartnerAccepted);
|
||||
internal readonly record struct DeclineTradeRuntimeCmd;
|
||||
internal readonly record struct ResetTradeRuntimeCmd;
|
||||
internal readonly record struct ClearFriendsRuntimeCmd;
|
||||
internal readonly record struct RequestLegacyFriendsRuntimeCmd;
|
||||
internal readonly record struct ModifyCharacterSquelchRuntimeCmd(
|
||||
|
|
@ -210,6 +228,22 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
_ => SendIfActive(bindings.SaveCharacterOptions));
|
||||
commands.Register<AddFriendRuntimeCmd>(
|
||||
command => SendIfActive(() => bindings.AddFriend(command.Name)));
|
||||
commands.Register<OpenTradeNegotiationsRuntimeCmd>(
|
||||
command => SendIfActive(() =>
|
||||
bindings.OpenTradeNegotiations(command.PartnerGuid)));
|
||||
commands.Register<CloseTradeNegotiationsRuntimeCmd>(
|
||||
_ => SendIfActive(bindings.CloseTradeNegotiations));
|
||||
commands.Register<AddToTradeRuntimeCmd>(
|
||||
command => SendIfActive(() => bindings.AddToTrade(command.ItemGuid)));
|
||||
commands.Register<AcceptTradeRuntimeCmd>(
|
||||
command => SendIfActive(() => bindings.AcceptTrade(
|
||||
command.PartnerGuid,
|
||||
command.SelfAccepted,
|
||||
command.PartnerAccepted)));
|
||||
commands.Register<DeclineTradeRuntimeCmd>(
|
||||
_ => SendIfActive(bindings.DeclineTrade));
|
||||
commands.Register<ResetTradeRuntimeCmd>(
|
||||
_ => SendIfActive(bindings.ResetTrade));
|
||||
commands.Register<RemoveFriendRuntimeCmd>(
|
||||
command => SendIfActive(() =>
|
||||
bindings.RemoveFriend(command.CharacterId)));
|
||||
|
|
|
|||
|
|
@ -270,7 +270,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_domain.Communication.Squelch,
|
||||
(text, type) => _domain.Communication.AddText(text, type),
|
||||
Fellowship: _domain.Runtime.FellowshipOwner,
|
||||
Allegiance: _domain.Runtime.AllegianceOwner));
|
||||
Allegiance: _domain.Runtime.AllegianceOwner,
|
||||
Trade: _domain.Runtime.TradeOwner));
|
||||
return new GraphicalSessionEventRoute(
|
||||
route,
|
||||
_domain.Runtime,
|
||||
|
|
@ -596,6 +597,18 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
RemoveFriend: session.SendRemoveFriend,
|
||||
ClearFriends: session.SendClearFriends,
|
||||
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
|
||||
// Secure trade (2026-08-14): AcceptTrade's echoed payload is
|
||||
// ACE-discarded (lane B); the initiator field carries the partner
|
||||
// guid — the only initiator identity ACE itself ever put on the
|
||||
// wire (the RegisterTrade landmine).
|
||||
OpenTradeNegotiations: session.SendOpenTradeNegotiations,
|
||||
CloseTradeNegotiations: session.SendCloseTradeNegotiations,
|
||||
AddToTrade: item => session.SendAddToTrade(item),
|
||||
AcceptTrade: (partner, selfAccepted, partnerAccepted) =>
|
||||
session.SendAcceptTrade(
|
||||
partner, 0d, 0u, partner, selfAccepted, partnerAccepted),
|
||||
DeclineTrade: session.SendDeclineTrade,
|
||||
ResetTrade: session.SendResetTrade,
|
||||
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
||||
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
||||
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
||||
|
|
|
|||
|
|
@ -182,6 +182,16 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's two secure-trade open paths surface here for the trade UI:
|
||||
/// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player
|
||||
/// (<c>DetermineUseResult @ 0x00588460</c> result 5), non-zero for
|
||||
/// drag-item-onto-player with the DragItemOnPlayerOpensSecureTrade
|
||||
/// option (<c>AttemptPlaceIn3D @ 0x00588600</c>). The subscriber
|
||||
/// (SecureTradeUiController) owns the open/stage sequencing.
|
||||
/// </summary>
|
||||
public event Action<uint, uint>? SecureTradeRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CM_Item::SendNotice_ShowPendingInPlayer</c>: the inventory
|
||||
/// panel inserts a waiting projection before the pickup request is sent.
|
||||
|
|
@ -1139,6 +1149,14 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (!string.IsNullOrWhiteSpace(action.Message))
|
||||
_toast?.Invoke(action.Message);
|
||||
break;
|
||||
case ItemPolicyActionKind.OpenSecureTrade:
|
||||
// Use-on-player (ItemHolder::DetermineUseResult
|
||||
// @ 0x00588460 result 5 → ClientTradeSystem::
|
||||
// AttemptToOpenTradeNegotiations @ 0x0056DEE0). The
|
||||
// action's ObjectId IS the target player.
|
||||
SecureTradeRequested?.Invoke(action.ObjectId, 0u);
|
||||
acted |= SecureTradeRequested is not null;
|
||||
break;
|
||||
default:
|
||||
_auxiliaryAction?.Invoke(action);
|
||||
PolicyActionRequested?.Invoke(action);
|
||||
|
|
@ -1161,6 +1179,13 @@ public sealed class ItemInteractionController : IDisposable
|
|||
{
|
||||
switch (action.Kind)
|
||||
{
|
||||
case ItemPolicyActionKind.StartSecureTrade:
|
||||
// Drag-item-onto-player with DragItemOnPlayerOpensSecureTrade
|
||||
// (ItemHolder::AttemptPlaceIn3D @ 0x00588600's option branch
|
||||
// → ClientTradeSystem::AttemptToTradeItem @ 0x0056DF80).
|
||||
// ObjectId = the dragged item, TargetId = the player.
|
||||
SecureTradeRequested?.Invoke(action.TargetId, action.ObjectId);
|
||||
break;
|
||||
case ItemPolicyActionKind.DropToWorld:
|
||||
TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.DropToWorld,
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -283,7 +283,10 @@ public sealed record SocialRuntimeBindings(
|
|||
Func<uint, RuntimeCommandResult> AllegianceSwear,
|
||||
Func<uint, RuntimeCommandResult> AllegianceBreak,
|
||||
Func<uint, RuntimeCommandResult> AllegianceKick,
|
||||
Func<bool, RuntimeCommandResult> AllegianceSetUpdateSubscription);
|
||||
Func<bool, RuntimeCommandResult> AllegianceSetUpdateSubscription,
|
||||
// Secure trade (2026-08-14): the third sibling J-owner's borrowed view.
|
||||
// Trailing/optional per the established compatibility convention.
|
||||
AcDream.Runtime.Gameplay.IRuntimeTradeView? Trade = null);
|
||||
|
||||
public sealed record InventoryRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
|
|
@ -478,6 +481,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountInventory();
|
||||
MountExternalContainer();
|
||||
MountVendor();
|
||||
MountSecureTrade();
|
||||
MountItemCooldowns();
|
||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||
BindToolbarPanelButtons();
|
||||
|
|
@ -607,6 +611,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
LinkStatusUiController?.Tick();
|
||||
IndicatorBarController?.Tick();
|
||||
JumpPowerbarController?.Tick();
|
||||
SecureTradeController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
ExternalContainerController?.Tick();
|
||||
SocialPanelController?.Tick();
|
||||
|
|
@ -3470,6 +3475,102 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[M4] retail vendor browse panel mounted from LayoutDesc 0x21000012.");
|
||||
}
|
||||
|
||||
/// <summary>The mounted secure-trade window's controller — null until
|
||||
/// <see cref="MountSecureTrade"/> runs (or when the trade view/layout is
|
||||
/// unavailable).</summary>
|
||||
public Layout.SecureTradeUiController? SecureTradeController { get; private set; }
|
||||
|
||||
private void MountSecureTrade()
|
||||
{
|
||||
if (_bindings.Social.Trade is not { } tradeView)
|
||||
{
|
||||
Console.WriteLine("[M4] secure trade: no runtime trade view bound.");
|
||||
return;
|
||||
}
|
||||
|
||||
ImportedLayout? layout;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
layout = LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
Layout.SecureTradeUiController.LayoutId,
|
||||
Layout.SecureTradeUiController.RootId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[M4] secure trade: LayoutDesc 0x2100000D root 0x1000007A not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var bus = _bindings.Options.CommandBus();
|
||||
Layout.SecureTradeUiController? controller =
|
||||
Layout.SecureTradeUiController.Bind(
|
||||
layout,
|
||||
new Layout.SecureTradeUiController.Bindings(
|
||||
Trade: tradeView,
|
||||
Objects: _bindings.Inventory.Objects,
|
||||
ResolveIcon: _bindings.Inventory.ResolveIcon,
|
||||
OpenTrade: partner => bus.Publish(
|
||||
new OpenTradeNegotiationsRuntimeCmd(partner)),
|
||||
CloseTrade: () => bus.Publish(
|
||||
new CloseTradeNegotiationsRuntimeCmd()),
|
||||
AddToTrade: item => bus.Publish(
|
||||
new AddToTradeRuntimeCmd(item)),
|
||||
AcceptTrade: (selfAccepted, partnerAccepted, partner) =>
|
||||
bus.Publish(new AcceptTradeRuntimeCmd(
|
||||
partner, selfAccepted, partnerAccepted)),
|
||||
DeclineTrade: () => bus.Publish(new DeclineTradeRuntimeCmd()),
|
||||
ResetTrade: () => bus.Publish(new ResetTradeRuntimeCmd()),
|
||||
SetWindowVisible: visible =>
|
||||
{
|
||||
if (visible) Host.ShowWindow(WindowNames.SecureTrade);
|
||||
else Host.HideWindow(WindowNames.SecureTrade);
|
||||
}));
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[M4] secure trade: required authored grids are missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.SecureTrade,
|
||||
// Root 0x1000007A authors content only (the in-screen copy's
|
||||
// bevel lives in LayoutDesc 0x21000005, not here) — the
|
||||
// shared nine-slice frame surrounds it, same as the vendor.
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
Left = MathF.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
|
||||
Top = MathF.Max(0f, (Host.Root.Height - root.Height) * 0.5f),
|
||||
ContentWidth = root.Width,
|
||||
ContentHeight = root.Height,
|
||||
MinWidth = root.Width,
|
||||
MinHeight = root.Height,
|
||||
Visible = false,
|
||||
ResizeX = false,
|
||||
ResizeY = false,
|
||||
ConstrainDragToParent = true,
|
||||
ConstrainResizeToParent = true,
|
||||
});
|
||||
|
||||
SecureTradeController = controller;
|
||||
Host.WindowManager.AttachController(WindowNames.SecureTrade, controller);
|
||||
// Both retail open paths (Use-on-player, drag-item-on-player) —
|
||||
// raised by ItemInteractionController's policy execution arms.
|
||||
_bindings.Inventory.ItemInteraction.SecureTradeRequested +=
|
||||
controller.RequestSecureTrade;
|
||||
Console.WriteLine(
|
||||
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
|
||||
}
|
||||
|
||||
private void MountItemCooldowns()
|
||||
{
|
||||
ItemCooldownAssets? assets;
|
||||
|
|
@ -3506,6 +3607,11 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_characterSheetSubscription?.Dispose();
|
||||
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
|
||||
WindowOpacity.Dispose();
|
||||
if (SecureTradeController is { } trade)
|
||||
{
|
||||
_bindings.Inventory.ItemInteraction.SecureTradeRequested -=
|
||||
trade.RequestSecureTrade;
|
||||
}
|
||||
},
|
||||
() => _itemConfirmationController?.Dispose(),
|
||||
() => _gameplayConfirmationController?.Dispose(),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ public static class WindowNames
|
|||
public const string Vitae = "vitae";
|
||||
public const string Examination = "examination";
|
||||
public const string Vendor = "vendor";
|
||||
public const string SecureTrade = "secure-trade";
|
||||
public const string Options = "options";
|
||||
public const string KeyboardConfig = "keyboard-config";
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,19 @@ public static class GameEventWiring
|
|||
Action<ClientCommandResponses.AllegianceUpdate>? onAllegianceUpdate = null,
|
||||
Action<uint /*weenieError*/>? onAllegianceUpdateDone = null,
|
||||
Action<uint /*weenieError*/>? onAllegianceUpdateAborted = null,
|
||||
Action<GameEvents.AllegianceLoginNotification>? onAllegianceLoginNotification = null)
|
||||
Action<GameEvents.AllegianceLoginNotification>? onAllegianceLoginNotification = null,
|
||||
// Secure trade (2026-08-14): the same Runtime-owned delegate-hole
|
||||
// shape as fellowship above. RuntimeTradeState is the consumer;
|
||||
// docs/research/2026-08-14-trade-laneB-wire.md is the wire SSOT.
|
||||
Action<GameEvents.RegisterTrade>? onTradeRegister = null,
|
||||
Action<uint /*endReason*/>? onTradeClose = null,
|
||||
Action<GameEvents.AddToTrade>? onTradeAdd = null,
|
||||
Action<GameEvents.RemoveFromTrade>? onTradeRemove = null,
|
||||
Action<uint /*whoAccepted*/>? onTradeAccept = null,
|
||||
Action<uint /*whoDeclined*/>? onTradeDecline = null,
|
||||
Action<uint /*whoReset*/>? onTradeReset = null,
|
||||
Action<GameEvents.TradeFailure>? onTradeFailure = null,
|
||||
Action? onTradeClearAcceptance = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
|
@ -316,6 +328,80 @@ public static class GameEventWiring
|
|||
});
|
||||
}
|
||||
|
||||
// ── Secure trade (0x01FD–0x0208) ──────────────────────────
|
||||
if (onTradeRegister is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.RegisterTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseRegisterTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeRegister(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeClose is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.CloseTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseCloseTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeClose(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeAdd is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.AddToTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseAddToTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeAdd(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeRemove is not null)
|
||||
{
|
||||
// ACE never emits 0x0201; registered defensively for the retail
|
||||
// handler's sake (Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00).
|
||||
registrar.Register(GameEventType.RemoveFromTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseRemoveFromTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeRemove(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeAccept is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.AcceptTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseAcceptTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeAccept(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeDecline is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.DeclineTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseDeclineTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeDecline(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeReset is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.ResetTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseResetTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeReset(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeFailure is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.TradeFailure, e =>
|
||||
{
|
||||
var p = GameEvents.ParseTradeFailure(e.Payload.Span);
|
||||
if (p is not null) onTradeFailure(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeClearAcceptance is not null)
|
||||
{
|
||||
// 0x0208 carries no payload (GameEventClearTradeAcceptance).
|
||||
registrar.Register(GameEventType.ClearTradeAcceptance, _ =>
|
||||
onTradeClearAcceptance());
|
||||
}
|
||||
|
||||
if (onConfirmationRequest is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>
|
||||
|
|
|
|||
|
|
@ -462,31 +462,98 @@ public static class GameEvents
|
|||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x0207 TradeFailure: server trade error code.</summary>
|
||||
public static uint? ParseTradeFailure(ReadOnlySpan<byte> payload)
|
||||
// ── Secure trade (docs/research/2026-08-14-trade-laneB-wire.md) ────────
|
||||
// ACE writers + retail parsers agree on every field below; retail
|
||||
// dispatch addresses cited per event.
|
||||
|
||||
/// <summary>0x01FD RegisterTrade: (initiator, partner, stamp). Retail
|
||||
/// <c>Handle_Trade__Recv_RegisterTrade @ 0x0056E050</c>. ACE landmine:
|
||||
/// BOTH sides receive initiator == partner == the non-self player's guid
|
||||
/// (never the true initiator) and stamp is always 0 — consumers must
|
||||
/// derive "who opened" themselves (lane B §quirks).</summary>
|
||||
public readonly record struct RegisterTrade(uint Initiator, uint Partner, ulong Stamp);
|
||||
|
||||
public static RegisterTrade? ParseRegisterTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 16) return null;
|
||||
return new RegisterTrade(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
||||
BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(8)));
|
||||
}
|
||||
|
||||
/// <summary>0x01FF CloseTrade: end reason (Normal=1, EnteredCombat=2,
|
||||
/// Canceled=0x51). Retail dispatch @ 0x006ACE90.</summary>
|
||||
public static uint? ParseCloseTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x0200 AddToTrade: (itemGuid, slotIndex).</summary>
|
||||
public readonly record struct AddToTrade(uint ItemGuid, uint SlotIndex);
|
||||
/// <summary>0x0200 AddToTrade: (itemGuid, side, slot). Side: 1 = the
|
||||
/// receiving client's own offer, 2 = the partner's. Slot is always 0
|
||||
/// from ACE. Retail dispatch @ 0x006ACE20 reads three dwords.</summary>
|
||||
public readonly record struct AddToTrade(uint ItemGuid, uint Side, uint SlotIndex);
|
||||
|
||||
public static AddToTrade? ParseAddToTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 8) return null;
|
||||
if (payload.Length < 12) return null;
|
||||
return new AddToTrade(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
|
||||
}
|
||||
|
||||
/// <summary>0x0201 RemoveFromTrade: (itemGuid, mode). Retail
|
||||
/// <c>Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00</c>; ACE never
|
||||
/// emits it (no per-item removal server-side) — parsed defensively.</summary>
|
||||
public readonly record struct RemoveFromTrade(uint ItemGuid, uint Mode);
|
||||
|
||||
public static RemoveFromTrade? ParseRemoveFromTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 8) return null;
|
||||
return new RemoveFromTrade(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
||||
}
|
||||
|
||||
/// <summary>0x0202 AcceptTrade: initiator guid.</summary>
|
||||
/// <summary>0x0202 AcceptTrade: who accepted (the client compares
|
||||
/// against its own guid for self-vs-partner — retail dispatch
|
||||
/// @ 0x006ACDF0).</summary>
|
||||
public static uint? ParseAcceptTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x0203 DeclineTrade: who declined.</summary>
|
||||
public static uint? ParseDeclineTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x0205 ResetTrade: who reset. ACE clears BOTH sides'
|
||||
/// staged items on either player's reset (lane B §quirks).</summary>
|
||||
public static uint? ParseResetTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x0207 TradeFailure: (itemGuid, WeenieError reason). Retail
|
||||
/// <c>Handle_Trade__Recv_TradeFailure @ 0x0056D990</c> removes the item
|
||||
/// locally before showing the notice.</summary>
|
||||
public readonly record struct TradeFailure(uint ItemGuid, uint Reason);
|
||||
|
||||
public static TradeFailure? ParseTradeFailure(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 8) return null;
|
||||
return new TradeFailure(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 0x0264 QueryItemManaResponse: (itemGuid, manaPercent, valid).
|
||||
/// Retail anchor: <c>CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0</c>
|
||||
|
|
|
|||
110
src/AcDream.Core.Net/Messages/TradeRequests.cs
Normal file
110
src/AcDream.Core.Net/Messages/TradeRequests.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Secure-trade GameAction builders — retail's <c>CM_Trade</c> senders,
|
||||
/// byte-checked against ACE's readers (the server acdream runs against).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire research: <c>docs/research/2026-08-14-trade-laneB-wire.md</c> —
|
||||
/// ACE, the retail decomp, and holtburger's independent Rust implementation
|
||||
/// agree on every field ACE implements. Retail senders:
|
||||
/// <c>Event_OpenTradeNegotiations @ 0x0056D300</c>,
|
||||
/// <c>Event_CloseTradeNegotiations @ 0x0056D1E0</c>,
|
||||
/// <c>Event_AddToTrade @ 0x0056D0D0</c>, <c>Event_AcceptTrade</c> (packing
|
||||
/// <c>Trade::Pack @ 0x005B9FF0</c>), <c>Event_DeclineTrade @ 0x0056D270</c>,
|
||||
/// <c>Event_ResetTrade @ 0x0056D3D0</c>. There is NO RemoveFromTrade
|
||||
/// C→S action — retail's per-item removal is client-local; ACE's only
|
||||
/// clear is the full-window ResetTrade (which clears BOTH sides).
|
||||
/// </remarks>
|
||||
public static class TradeRequests
|
||||
{
|
||||
public const uint GameActionEnvelope = 0xF7B1u;
|
||||
public const uint OpenTradeNegotiationsOpcode = 0x01F6u;
|
||||
public const uint CloseTradeNegotiationsOpcode = 0x01F7u;
|
||||
public const uint AddToTradeOpcode = 0x01F8u;
|
||||
public const uint AcceptTradeOpcode = 0x01FAu;
|
||||
public const uint DeclineTradeOpcode = 0x01FBu;
|
||||
public const uint ResetTradeOpcode = 0x0204u;
|
||||
|
||||
/// <summary>Open trade with <paramref name="partnerGuid"/> — ACE walks
|
||||
/// the initiator into range (<c>CreateMoveToChain</c>) before both sides
|
||||
/// receive RegisterTrade.</summary>
|
||||
public static byte[] BuildOpenTradeNegotiations(
|
||||
uint gameActionSequence, uint partnerGuid)
|
||||
{
|
||||
byte[] body = new byte[16];
|
||||
WriteHeader(body, gameActionSequence, OpenTradeNegotiationsOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), partnerGuid);
|
||||
return body;
|
||||
}
|
||||
|
||||
public static byte[] BuildCloseTradeNegotiations(uint gameActionSequence)
|
||||
=> BuildEmpty(gameActionSequence, CloseTradeNegotiationsOpcode);
|
||||
|
||||
/// <summary>Stage an item. <paramref name="tradeSlot"/> is ACE-ignored
|
||||
/// (it always echoes slot 0); retail sends its grid slot.</summary>
|
||||
public static byte[] BuildAddToTrade(
|
||||
uint gameActionSequence, uint itemGuid, uint tradeSlot = 0u)
|
||||
{
|
||||
byte[] body = new byte[20];
|
||||
WriteHeader(body, gameActionSequence, AddToTradeOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), tradeSlot);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accept the current offer. The payload mirrors retail's
|
||||
/// <c>Trade::Pack</c> fixed fields; ACE parses and then DISCARDS every
|
||||
/// one of them (<c>HandleActionAcceptTrade()</c> takes zero arguments —
|
||||
/// server state is fully self-derived), so the two trailing
|
||||
/// PackableList<ContentProfile> item lists retail appends are sent
|
||||
/// as zero-count lists here (register row AD-94).
|
||||
/// </summary>
|
||||
public static byte[] BuildAcceptTrade(
|
||||
uint gameActionSequence,
|
||||
uint partnerGuid,
|
||||
double tradeStamp,
|
||||
uint tradeStatus,
|
||||
uint initiatorGuid,
|
||||
bool initiatorAccepts,
|
||||
bool partnerAccepts)
|
||||
{
|
||||
byte[] body = new byte[48];
|
||||
WriteHeader(body, gameActionSequence, AcceptTradeOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), partnerGuid);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(body.AsSpan(16), tradeStamp);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), tradeStatus);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(28), initiatorGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(32), initiatorAccepts ? 1u : 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(36), partnerAccepts ? 1u : 0u);
|
||||
// Two zero-count item lists (see remarks / AD-94).
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(40), 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(44), 0u);
|
||||
return body;
|
||||
}
|
||||
|
||||
public static byte[] BuildDeclineTrade(uint gameActionSequence)
|
||||
=> BuildEmpty(gameActionSequence, DeclineTradeOpcode);
|
||||
|
||||
/// <summary>Clear the trade window. ACE clears BOTH players' staged
|
||||
/// items, not just the sender's (Player_Trade landmine — lane B §quirks).</summary>
|
||||
public static byte[] BuildResetTrade(uint gameActionSequence)
|
||||
=> BuildEmpty(gameActionSequence, ResetTradeOpcode);
|
||||
|
||||
private static byte[] BuildEmpty(uint gameActionSequence, uint opcode)
|
||||
{
|
||||
byte[] body = new byte[12];
|
||||
WriteHeader(body, gameActionSequence, opcode);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static void WriteHeader(byte[] body, uint sequence, uint opcode)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
|
||||
}
|
||||
}
|
||||
|
|
@ -2561,6 +2561,65 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid));
|
||||
}
|
||||
|
||||
// ── Secure trade (docs/research/2026-08-14-trade-laneB-wire.md) ────────
|
||||
|
||||
/// <summary>Open secure trade with another player — retail
|
||||
/// <c>CM_Trade::Event_OpenTradeNegotiations @ 0x0056D300</c>.</summary>
|
||||
public void SendOpenTradeNegotiations(uint partnerGuid)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildOpenTradeNegotiations(seq, partnerGuid));
|
||||
}
|
||||
|
||||
/// <summary>Close the trade window — <c>Event_CloseTradeNegotiations
|
||||
/// @ 0x0056D1E0</c>.</summary>
|
||||
public void SendCloseTradeNegotiations()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildCloseTradeNegotiations(seq));
|
||||
}
|
||||
|
||||
/// <summary>Stage an item into the trade — <c>Event_AddToTrade
|
||||
/// @ 0x0056D0D0</c>.</summary>
|
||||
public void SendAddToTrade(uint itemGuid, uint tradeSlot = 0u)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildAddToTrade(seq, itemGuid, tradeSlot));
|
||||
}
|
||||
|
||||
/// <summary>Accept the current offer — <c>Event_AcceptTrade</c> packing
|
||||
/// <c>Trade::Pack @ 0x005B9FF0</c>'s fixed fields (ACE discards the
|
||||
/// payload entirely; see TradeRequests.BuildAcceptTrade).</summary>
|
||||
public void SendAcceptTrade(
|
||||
uint partnerGuid,
|
||||
double tradeStamp,
|
||||
uint tradeStatus,
|
||||
uint initiatorGuid,
|
||||
bool initiatorAccepts,
|
||||
bool partnerAccepts)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildAcceptTrade(
|
||||
seq, partnerGuid, tradeStamp, tradeStatus,
|
||||
initiatorGuid, initiatorAccepts, partnerAccepts));
|
||||
}
|
||||
|
||||
/// <summary>Withdraw a previous accept — <c>Event_DeclineTrade
|
||||
/// @ 0x0056D270</c>.</summary>
|
||||
public void SendDeclineTrade()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildDeclineTrade(seq));
|
||||
}
|
||||
|
||||
/// <summary>Clear the trade window — <c>Event_ResetTrade @ 0x0056D3D0</c>.
|
||||
/// ACE clears BOTH sides' staged items (lane B §quirks).</summary>
|
||||
public void SendResetTrade()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildResetTrade(seq));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send retail GiveObjectRequest (0x00CD). Retail
|
||||
/// <c>CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00</c> writes
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@ public enum GameRuntimeTeardownStage
|
|||
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
|
||||
FellowshipDisposed = 1 << 9,
|
||||
AllegianceDisposed = 1 << 10,
|
||||
IdentityDisposed = 1 << 11,
|
||||
EntityObjectsDisposed = 1 << 12,
|
||||
// Secure trade (2026-08-14): third sibling J-owner, same shape.
|
||||
TradeDisposed = 1 << 11,
|
||||
IdentityDisposed = 1 << 12,
|
||||
EntityObjectsDisposed = 1 << 13,
|
||||
Complete =
|
||||
HostLeasesReleased
|
||||
| EventsDetached
|
||||
|
|
@ -51,6 +53,7 @@ public enum GameRuntimeTeardownStage
|
|||
| CommunicationDisposed
|
||||
| FellowshipDisposed
|
||||
| AllegianceDisposed
|
||||
| TradeDisposed
|
||||
| IdentityDisposed
|
||||
| EntityObjectsDisposed,
|
||||
}
|
||||
|
|
@ -94,6 +97,7 @@ internal enum GameRuntimeConstructionPoint
|
|||
CommunicationCreated,
|
||||
FellowshipCreated,
|
||||
AllegianceCreated,
|
||||
TradeCreated,
|
||||
MovementCreated,
|
||||
ActionsCreated,
|
||||
EnvironmentCreated,
|
||||
|
|
@ -111,6 +115,7 @@ internal sealed class GameRuntimeConstructionContext
|
|||
public RuntimeCommunicationState? Communication { get; set; }
|
||||
public RuntimeFellowshipState? Fellowship { get; set; }
|
||||
public RuntimeAllegianceState? Allegiance { get; set; }
|
||||
public RuntimeTradeState? Trade { get; set; }
|
||||
public RuntimeLocalPlayerMovementState? Movement { get; set; }
|
||||
public RuntimeActionState? Actions { get; set; }
|
||||
public GameRuntimeEventHub? Events { get; set; }
|
||||
|
|
@ -126,7 +131,7 @@ public sealed class GameRuntime
|
|||
IRuntimeEventSource,
|
||||
IDisposable
|
||||
{
|
||||
private const int TeardownStageCount = 13;
|
||||
private const int TeardownStageCount = 14;
|
||||
|
||||
private readonly object _lifetimeGate = new();
|
||||
private readonly Dictionary<long, string> _hostLeases = [];
|
||||
|
|
@ -248,6 +253,16 @@ public sealed class GameRuntime
|
|||
context,
|
||||
faultInjection);
|
||||
|
||||
// Secure trade (2026-08-14): third sibling J-owner —
|
||||
// session-scoped like fellowship (a disconnect closes the trade
|
||||
// server-side), clears at every generation reset.
|
||||
context.Trade = new RuntimeTradeState();
|
||||
construction.Own(context.Trade);
|
||||
Fault(
|
||||
GameRuntimeConstructionPoint.TradeCreated,
|
||||
context,
|
||||
faultInjection);
|
||||
|
||||
context.Movement = new RuntimeLocalPlayerMovementState();
|
||||
// Campaign CH slice CH2: local jump refusals (CommenceJump/
|
||||
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
|
||||
|
|
@ -302,7 +317,8 @@ public sealed class GameRuntime
|
|||
context.Character,
|
||||
context.PlayerIdentity,
|
||||
context.Fellowship,
|
||||
context.Allegiance);
|
||||
context.Allegiance,
|
||||
context.Trade);
|
||||
|
||||
context.Movement.AttachPhysicsPublication(
|
||||
new RuntimeLocalPlayerPhysicsPublicationState(
|
||||
|
|
@ -357,6 +373,7 @@ public sealed class GameRuntime
|
|||
CommunicationOwner = context.Communication;
|
||||
FellowshipOwner = context.Fellowship;
|
||||
AllegianceOwner = context.Allegiance;
|
||||
TradeOwner = context.Trade;
|
||||
MovementOwner = context.Movement;
|
||||
ActionOwner = context.Actions;
|
||||
EnvironmentOwner = environment;
|
||||
|
|
@ -462,6 +479,9 @@ public sealed class GameRuntime
|
|||
public RuntimeCommunicationState CommunicationOwner { get; }
|
||||
public RuntimeFellowshipState FellowshipOwner { get; }
|
||||
public RuntimeAllegianceState AllegianceOwner { get; }
|
||||
|
||||
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
|
||||
public RuntimeTradeState TradeOwner { get; }
|
||||
public RuntimeActionState ActionOwner { get; }
|
||||
public RuntimeLocalPlayerMovementState MovementOwner { get; }
|
||||
internal RuntimeLocalPlayerPhysicsPublicationState
|
||||
|
|
@ -507,6 +527,8 @@ public sealed class GameRuntime
|
|||
public IRuntimeChatView Chat => CommunicationOwner.View;
|
||||
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
|
||||
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;
|
||||
|
||||
public IRuntimeTradeView Trade => TradeOwner.View;
|
||||
public IRuntimeActionView Actions => ActionOwner.View;
|
||||
public IRuntimeMovementView Movement => MovementOwner.View;
|
||||
public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner;
|
||||
|
|
@ -607,7 +629,8 @@ public sealed class GameRuntime
|
|||
ActionOwner,
|
||||
MovementOwner,
|
||||
FellowshipOwner,
|
||||
AllegianceOwner),
|
||||
AllegianceOwner,
|
||||
TradeOwner),
|
||||
EnvironmentOwner.CaptureOwnership(),
|
||||
TransitOwner.CaptureOwnership(),
|
||||
GenerationReset.CaptureSnapshot(),
|
||||
|
|
@ -733,16 +756,22 @@ public sealed class GameRuntime
|
|||
9 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.FellowshipDisposed
|
||||
& ~GameRuntimeTeardownStage.AllegianceDisposed
|
||||
& ~GameRuntimeTeardownStage.TradeDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
10 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.AllegianceDisposed
|
||||
& ~GameRuntimeTeardownStage.TradeDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
11 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.TradeDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
12 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
13 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
_ => GameRuntimeTeardownStage.Complete,
|
||||
};
|
||||
|
|
@ -794,9 +823,12 @@ public sealed class GameRuntime
|
|||
AllegianceOwner.Dispose();
|
||||
return AllegianceOwner.CaptureOwnership().IsConverged;
|
||||
case 11:
|
||||
TradeOwner.Dispose();
|
||||
return TradeOwner.CaptureOwnership().IsConverged;
|
||||
case 12:
|
||||
PlayerIdentity.Dispose();
|
||||
return PlayerIdentity.CaptureOwnership().IsConverged;
|
||||
case 12:
|
||||
case 13:
|
||||
EntityObjects.Dispose();
|
||||
return EntityObjects.CaptureOwnership().IsConverged
|
||||
&& EntityObjects.Physics.CaptureOwnership().IsConverged;
|
||||
|
|
@ -819,8 +851,9 @@ public sealed class GameRuntime
|
|||
8 => CommunicationOwner.CaptureOwnership().IsConverged,
|
||||
9 => FellowshipOwner.CaptureOwnership().IsConverged,
|
||||
10 => AllegianceOwner.CaptureOwnership().IsConverged,
|
||||
11 => PlayerIdentity.CaptureOwnership().IsConverged,
|
||||
12 => EntityObjects.CaptureOwnership().IsConverged
|
||||
11 => TradeOwner.CaptureOwnership().IsConverged,
|
||||
12 => PlayerIdentity.CaptureOwnership().IsConverged,
|
||||
13 => EntityObjects.CaptureOwnership().IsConverged
|
||||
&& EntityObjects.Physics.CaptureOwnership().IsConverged,
|
||||
_ => true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
307
src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs
Normal file
307
src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs
Normal 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 0x01FD–0x0208 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -53,15 +53,22 @@ public enum RuntimeGenerationResetStage
|
|||
/// from the precedent it named).
|
||||
/// </summary>
|
||||
Allegiance = 13,
|
||||
BeginEntityRetirement = 14,
|
||||
RetireEntities = 15,
|
||||
DrainHostProjection = 16,
|
||||
CompleteCanonicalEntities = 17,
|
||||
CompleteHostProjection = 18,
|
||||
ChatIdentity = 19,
|
||||
PlayerSnapshots = 20,
|
||||
PlayerIdentity = 21,
|
||||
Complete = 22,
|
||||
/// <summary>
|
||||
/// Secure trade (2026-08-14): the trade window is session-scoped — a
|
||||
/// disconnect closes the trade server-side (ACE tears the negotiation
|
||||
/// down with the session), so the third sibling J-owner clears here
|
||||
/// beside its fellowship/allegiance precedents.
|
||||
/// </summary>
|
||||
Trade = 14,
|
||||
BeginEntityRetirement = 15,
|
||||
RetireEntities = 16,
|
||||
DrainHostProjection = 17,
|
||||
CompleteCanonicalEntities = 18,
|
||||
CompleteHostProjection = 19,
|
||||
ChatIdentity = 20,
|
||||
PlayerSnapshots = 21,
|
||||
PlayerIdentity = 22,
|
||||
Complete = 23,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeGenerationResetSnapshot(
|
||||
|
|
@ -111,6 +118,7 @@ public sealed class RuntimeGenerationReset
|
|||
private readonly RuntimeLocalPlayerIdentityState _identity;
|
||||
private readonly RuntimeFellowshipState _fellowship;
|
||||
private readonly RuntimeAllegianceState _allegiance;
|
||||
private readonly RuntimeTradeState _trade;
|
||||
private ResetState? _state;
|
||||
private RuntimeGenerationToken _lastCompletedGeneration;
|
||||
private bool _hasCompletedGeneration;
|
||||
|
|
@ -127,7 +135,8 @@ public sealed class RuntimeGenerationReset
|
|||
RuntimeCharacterState character,
|
||||
RuntimeLocalPlayerIdentityState identity,
|
||||
RuntimeFellowshipState fellowship,
|
||||
RuntimeAllegianceState allegiance)
|
||||
RuntimeAllegianceState allegiance,
|
||||
RuntimeTradeState trade)
|
||||
{
|
||||
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
||||
_communication = communication
|
||||
|
|
@ -147,6 +156,7 @@ public sealed class RuntimeGenerationReset
|
|||
?? throw new ArgumentNullException(nameof(fellowship));
|
||||
_allegiance = allegiance
|
||||
?? throw new ArgumentNullException(nameof(allegiance));
|
||||
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
|
||||
}
|
||||
|
||||
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
||||
|
|
@ -320,6 +330,9 @@ public sealed class RuntimeGenerationReset
|
|||
case RuntimeGenerationResetStage.Allegiance:
|
||||
Advance(state, _allegiance.ResetSession);
|
||||
break;
|
||||
case RuntimeGenerationResetStage.Trade:
|
||||
Advance(state, _trade.Clear);
|
||||
break;
|
||||
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
||||
_ = _entityObjects.BeginSessionClear();
|
||||
state.Retirements = _entityObjects
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ public static class RuntimeSimulationOwnership
|
|||
RuntimeActionState actions,
|
||||
RuntimeLocalPlayerMovementState movement,
|
||||
RuntimeFellowshipState fellowship,
|
||||
RuntimeAllegianceState allegiance)
|
||||
RuntimeAllegianceState allegiance,
|
||||
RuntimeTradeState trade)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entityObjects);
|
||||
return new RuntimeSimulationOwnershipSnapshot(
|
||||
|
|
@ -43,6 +44,7 @@ public static class RuntimeSimulationOwnership
|
|||
actions,
|
||||
movement,
|
||||
fellowship,
|
||||
allegiance));
|
||||
allegiance,
|
||||
trade));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,10 @@ public sealed record LiveSocialSessionBindings(
|
|||
// compiles unchanged (docs/research/2026-08-11-fa-acdream-seams.md
|
||||
// §2.4 — "the established compatibility convention").
|
||||
RuntimeFellowshipState? Fellowship = null,
|
||||
RuntimeAllegianceState? Allegiance = null);
|
||||
RuntimeAllegianceState? Allegiance = null,
|
||||
// Secure trade (2026-08-14): the third sibling J-owner, same
|
||||
// trailing/optional compatibility convention.
|
||||
RuntimeTradeState? Trade = null);
|
||||
|
||||
/// <summary>
|
||||
/// Owns every inbound subscription for one exact live session. Domain state
|
||||
|
|
@ -281,6 +284,36 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
? notice => allegianceLogin.ApplyLoginNotification(
|
||||
notice.CharacterGuid,
|
||||
notice.IsLoggedIn)
|
||||
: null,
|
||||
// Secure trade (2026-08-14): same conditional delegate-hole
|
||||
// discipline. Self-vs-partner comparisons use the exact
|
||||
// player guid the fellowship holes above already borrow.
|
||||
onTradeRegister: social.Trade is { } tradeRegister
|
||||
? update => tradeRegister.ApplyRegister(update, inventory.PlayerGuid())
|
||||
: null,
|
||||
onTradeClose: social.Trade is { } tradeClose
|
||||
? _ => tradeClose.ApplyClose()
|
||||
: null,
|
||||
onTradeAdd: social.Trade is { } tradeAdd
|
||||
? tradeAdd.ApplyAdd
|
||||
: null,
|
||||
onTradeRemove: social.Trade is { } tradeRemove
|
||||
? tradeRemove.ApplyRemove
|
||||
: null,
|
||||
onTradeAccept: social.Trade is { } tradeAccept
|
||||
? whoAccepted => tradeAccept.ApplyAccept(whoAccepted, inventory.PlayerGuid())
|
||||
: null,
|
||||
onTradeDecline: social.Trade is { } tradeDecline
|
||||
? whoDeclined => tradeDecline.ApplyDecline(whoDeclined, inventory.PlayerGuid())
|
||||
: null,
|
||||
onTradeReset: social.Trade is { } tradeReset
|
||||
? _ => tradeReset.ApplyReset()
|
||||
: null,
|
||||
onTradeFailure: social.Trade is { } tradeFailure
|
||||
? tradeFailure.ApplyFailure
|
||||
: null,
|
||||
onTradeClearAcceptance: social.Trade is { } tradeClear
|
||||
? tradeClear.ApplyClearAcceptance
|
||||
: null));
|
||||
ConstructionCheckpoint();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue