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

@ -239,6 +239,7 @@ public sealed class GameRuntimeTests
GameRuntimeTeardownStage.CommunicationDisposed,
GameRuntimeTeardownStage.FellowshipDisposed,
GameRuntimeTeardownStage.AllegianceDisposed,
GameRuntimeTeardownStage.TradeDisposed,
GameRuntimeTeardownStage.IdentityDisposed,
GameRuntimeTeardownStage.EntityObjectsDisposed,
];

View file

@ -20,6 +20,7 @@ public sealed class RuntimeGameplayOwnershipTests
var movement = new RuntimeLocalPlayerMovementState();
var fellowship = new RuntimeFellowshipState();
var allegiance = new RuntimeAllegianceState();
var trade = new RuntimeTradeState();
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
inventory.Transactions.IncrementBusyCount();
@ -38,7 +39,8 @@ public sealed class RuntimeGameplayOwnershipTests
actions,
movement,
fellowship,
allegiance);
allegiance,
trade);
Assert.False(populated.IsConverged);
movement.Dispose();
@ -48,6 +50,7 @@ public sealed class RuntimeGameplayOwnershipTests
inventory.Dispose();
fellowship.Dispose();
allegiance.Dispose();
trade.Dispose();
entities.Dispose();
RuntimeSimulationOwnershipSnapshot retired =
@ -59,7 +62,8 @@ public sealed class RuntimeGameplayOwnershipTests
actions,
movement,
fellowship,
allegiance);
allegiance,
trade);
Assert.True(retired.IsConverged);
Assert.True(retired.EntityObjects.IsDisposed);
Assert.True(retired.Physics.IsDisposed);
@ -76,6 +80,7 @@ public sealed class RuntimeGameplayOwnershipTests
var movement = new RuntimeLocalPlayerMovementState();
var fellowship = new RuntimeFellowshipState();
var allegiance = new RuntimeAllegianceState();
var trade = new RuntimeTradeState();
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
inventory.Shortcuts.Changed += static () => { };
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
@ -139,7 +144,8 @@ public sealed class RuntimeGameplayOwnershipTests
actions,
movement,
fellowship,
allegiance);
allegiance,
trade);
Assert.False(populated.IsConverged);
Assert.Equal(1, populated.Inventory.ShortcutCount);
@ -161,6 +167,7 @@ public sealed class RuntimeGameplayOwnershipTests
inventory.Dispose();
fellowship.Dispose();
allegiance.Dispose();
trade.Dispose();
subscription.Dispose();
RuntimeGameplayOwnershipSnapshot retired =
@ -171,7 +178,8 @@ public sealed class RuntimeGameplayOwnershipTests
actions,
movement,
fellowship,
allegiance);
allegiance,
trade);
Assert.True(retired.IsConverged);
Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount);
@ -191,6 +199,7 @@ public sealed class RuntimeGameplayOwnershipTests
var movement = new RuntimeLocalPlayerMovementState();
var fellowship = new RuntimeFellowshipState();
var allegiance = new RuntimeAllegianceState();
var trade = new RuntimeTradeState();
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
@ -215,6 +224,7 @@ public sealed class RuntimeGameplayOwnershipTests
communication.Dispose();
fellowship.Dispose();
allegiance.Dispose();
trade.Dispose();
RuntimeGameplayOwnershipSnapshot retired =
RuntimeGameplayOwnership.Capture(
@ -224,7 +234,8 @@ public sealed class RuntimeGameplayOwnershipTests
actions,
movement,
fellowship,
allegiance);
allegiance,
trade);
Assert.True(retired.IsConverged);
Assert.Equal(1, retired.Communication.DispatchFailureCount);

View file

@ -0,0 +1,142 @@
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
/// <summary>
/// Secure-trade owner conformance (2026-08-14): the 0x01FD0x0208 event
/// family against the lane B wire truths — including ACE's
/// wrong-initiator RegisterTrade, its both-sides ResetTrade, and retail's
/// remove-before-notice TradeFailure handling.
/// </summary>
public sealed class RuntimeTradeStateTests
{
private const uint Self = 0x50000001u;
private const uint Partner = 0x50000002u;
private const uint ItemA = 0x60000001u;
private const uint ItemB = 0x60000002u;
[Fact]
public void RegisterOpensAndDerivesPartnerDespiteAceGuidLandmine()
{
using var trade = new RuntimeTradeState();
// ACE sends initiator == partner == the non-self guid on BOTH sides.
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
Assert.True(snapshot.IsOpen);
Assert.Equal(Partner, snapshot.PartnerGuid);
Assert.False(snapshot.SelfAccepted);
Assert.False(snapshot.PartnerAccepted);
}
[Fact]
public void AddStagesPerSideAndDropsBothAcceptances()
{
using var trade = new RuntimeTradeState();
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.ApplyAccept(Self, Self);
trade.ApplyAccept(Partner, Self);
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
trade.ApplyAdd(new GameEvents.AddToTrade(ItemB, (uint)RuntimeTradeSide.Partner, 0u));
// Duplicate echo is a no-op stage-wise.
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
Assert.Equal(1, snapshot.SelfItemCount);
Assert.Equal(1, snapshot.PartnerItemCount);
Assert.Equal([ItemA], trade.View.GetItems(RuntimeTradeSide.Self));
Assert.Equal([ItemB], trade.View.GetItems(RuntimeTradeSide.Partner));
// Staging invalidates prior acceptance (the ClearTradeAcceptance
// mirror).
Assert.False(snapshot.SelfAccepted);
Assert.False(snapshot.PartnerAccepted);
}
[Fact]
public void AcceptDeclineTrackSelfVersusPartnerByGuid()
{
using var trade = new RuntimeTradeState();
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.ApplyAccept(Partner, Self);
Assert.True(trade.View.Snapshot.PartnerAccepted);
Assert.False(trade.View.Snapshot.SelfAccepted);
trade.ApplyAccept(Self, Self);
Assert.True(trade.View.Snapshot.SelfAccepted);
trade.ApplyDecline(Partner, Self);
Assert.False(trade.View.Snapshot.PartnerAccepted);
Assert.True(trade.View.Snapshot.SelfAccepted);
}
[Fact]
public void ResetClearsBothSidesButKeepsTheWindowOpen()
{
using var trade = new RuntimeTradeState();
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
trade.ApplyAdd(new GameEvents.AddToTrade(ItemB, (uint)RuntimeTradeSide.Partner, 0u));
trade.ApplyAccept(Self, Self);
trade.ApplyReset();
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
Assert.True(snapshot.IsOpen);
Assert.Equal(0, snapshot.SelfItemCount);
Assert.Equal(0, snapshot.PartnerItemCount);
Assert.False(snapshot.SelfAccepted);
}
[Fact]
public void FailureRemovesTheItemAndRecordsTheReason()
{
using var trade = new RuntimeTradeState();
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
trade.ApplyFailure(new GameEvents.TradeFailure(ItemA, 0x426u));
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
Assert.Equal(0, snapshot.SelfItemCount);
Assert.Equal(ItemA, snapshot.LastFailureItemGuid);
Assert.Equal(0x426u, snapshot.LastFailureReason);
}
[Fact]
public void CloseAndGenerationClearConvergeTheLedger()
{
var trade = new RuntimeTradeState();
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
trade.ApplyClose();
Assert.False(trade.View.Snapshot.IsOpen);
Assert.Equal(0, trade.View.Snapshot.SelfItemCount);
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.Clear();
Assert.False(trade.View.Snapshot.IsOpen);
trade.Dispose();
Assert.True(trade.CaptureOwnership().IsConverged);
}
[Fact]
public void EventsBeforeRegisterAreIgnored()
{
using var trade = new RuntimeTradeState();
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
trade.ApplyAccept(Partner, Self);
trade.ApplyReset();
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
Assert.False(snapshot.IsOpen);
Assert.Equal(0, snapshot.SelfItemCount);
Assert.False(snapshot.PartnerAccepted);
}
}