feat(inventory): port retail item giving (#216)

Preserve SmartBox drag-release coordinates, route the picked 3-D target through the retail AttemptPlaceIn3D policy, and send authoritative GiveObject requests with the selected stack quantity. Honor PlayerDescription's player secure-trade option and document the remaining trade-system boundary.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-13 20:28:38 +02:00
parent a944e49b9c
commit 30d294506c
13 changed files with 421 additions and 10 deletions

View file

@ -21,10 +21,12 @@ public sealed class ItemInteractionControllerTests
public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
public readonly List<uint> Drops = new();
public readonly List<(uint Item, uint Amount)> SplitDrops = new();
public readonly List<(uint Target, uint Item, uint Amount)> Gives = new();
public readonly List<string> Toasts = new();
public readonly StackSplitQuantityState SplitQuantity = new();
public uint SelectedObject;
public bool NonCombatMode;
public bool DragOnPlayerOpensSecureTrade = true;
public long Now = 1_000;
public Harness()
@ -59,7 +61,9 @@ public sealed class ItemInteractionControllerTests
stackSplitQuantity: SplitQuantity,
inNonCombatMode: () => NonCombatMode,
sendPutItemInContainer: (item, container, placement) =>
Puts.Add((item, container, placement)));
Puts.Add((item, container, placement)),
sendGive: (target, item, amount) => Gives.Add((target, item, amount)),
dragOnPlayerOpensSecureTrade: () => DragOnPlayerOpensSecureTrade);
}
public ItemInteractionController Controller { get; }
@ -697,6 +701,110 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
}
[Fact]
public void InventoryDragOnNpc_sendsGiveWithoutOptimisticInventoryMutation()
{
var h = new Harness();
const uint item = 0x50000A71u;
const uint npc = 0x50001001u;
h.AddContained(item);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = npc,
Name = "Starter Exit Warden",
Type = ItemType.Creature,
});
var payload = new ItemDragPayload(
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
Assert.True(h.Controller.PlaceIn3D(payload, npc));
Assert.Equal(new[] { (npc, item, 1u) }, h.Gives);
Assert.Empty(h.Drops);
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
Assert.Equal(1, h.Objects.Get(item)!.StackSize);
}
[Fact]
public void SelectedPartialStackDragOnNpc_sendsSelectedGiveAmount()
{
var h = new Harness();
const uint item = 0x50000A72u;
const uint npc = 0x50001002u;
h.AddContained(item, value => value.StackSize = 10);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = npc,
Type = ItemType.Creature,
});
h.SelectedObject = item;
h.SplitQuantity.Reset(10u);
h.SplitQuantity.SetValue(3u);
var payload = new ItemDragPayload(
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
Assert.True(h.Controller.PlaceIn3D(payload, npc));
Assert.Equal(new[] { (npc, item, 3u) }, h.Gives);
Assert.Equal(10, h.Objects.Get(item)!.StackSize);
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
}
[Fact]
public void InventoryDragOnNonCreature_usesRetailGroundFallback()
{
var h = new Harness();
const uint item = 0x50000A73u;
const uint scenery = 0x50002001u;
h.AddContained(item);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = scenery,
Type = ItemType.Misc,
});
var payload = new ItemDragPayload(
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
Assert.True(h.Controller.PlaceIn3D(payload, scenery));
Assert.Empty(h.Gives);
Assert.Equal(new[] { item }, h.Drops);
Assert.Equal(0u, h.Objects.Get(item)!.ContainerId);
}
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public void InventoryDragOnPlayer_honorsRetailSecureTradeOption(
bool opensSecureTrade,
bool sendsGive)
{
var h = new Harness { DragOnPlayerOpensSecureTrade = opensSecureTrade };
const uint item = 0x50000A74u;
const uint targetPlayer = 0x50003001u;
h.AddContained(item);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = targetPlayer,
Type = ItemType.Creature,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Player,
});
var payload = new ItemDragPayload(
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
bool result = h.Controller.PlaceIn3D(payload, targetPlayer);
Assert.Equal(sendsGive, result);
if (sendsGive)
Assert.Equal(new[] { (targetPlayer, item, 1u) }, h.Gives);
else
{
Assert.Empty(h.Gives);
Assert.Contains(h.Toasts, text => text.Contains("Secure trade", StringComparison.Ordinal));
}
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
}
[Fact]
public void SelectedPartialStackDragOutsideUi_splitsWithoutMovingOriginal()
{