diff --git a/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md b/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md index 01761ea6..212dae14 100644 --- a/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md +++ b/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md @@ -155,20 +155,22 @@ This makes rollback faithful in **both** directions (today an unwield-reject los ```csharp /// Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set -/// ContainerId = WielderId = wielderGuid and CurrentlyEquippedLocation = equipMask (matching the +/// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask (matching the /// server's WieldObject 0x0023 confirm), firing ObjectMoved for an immediate repaint. The caller /// sends GetAndWieldItem; ConfirmMove (on the 0x0023 echo) / RollbackMove (on 0x00A0) reconcile. public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask) { if (!_objects.TryGetValue(itemId, out var item)) return false; RecordPending(itemId, item); - item.WielderId = wielderGuid; // unambiguously the player's gear during the optimistic window return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask); } ``` `MoveItem` already sets `ContainerId` + `CurrentlyEquippedLocation` + reindexes + fires `ObjectMoved`. -Setting `WielderId` here matches the steady state (the item's own CreateObject carries `Wielder`). +It does **not** write `WielderId` — acdream's `WieldObject 0x0023` confirm is also ContainerId-based (it +never sets `WielderId`), so the optimistic state equals the confirmed state and rollback fully restores +through `MoveItem` alone. (An equipped item is detected as the player's via `ContainerId == player`; +login-equipped items match via `WielderId` from their own CreateObject. Decided at the Task-3 code review.) --- @@ -239,9 +241,10 @@ jewelry — a single equip bit intersects exactly one slot mask). If found, `cel iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects))`; else `cell.Clear()` (transparent — nothing drawn). -`Concerns(o)`: repaint when `o.CurrentlyEquippedLocation != None || o.WielderId == player || o.ContainerId == player`; -`OnObjectMoved` also repaints when from/to is the player (an item being wielded/unwielded). Mirror -`InventoryController`'s debounce. +`Concerns(o)`: repaint when `o.WielderId == player || o.ContainerId == player` (player-scoped — an NPC's +wielded item carries `CurrentlyEquippedLocation` too and must NOT leak onto the doll; a player-equipped item +always has `WielderId==player` or `ContainerId==player`). `OnObjectMoved` also repaints when from/to is the +player (an item being wielded/unwielded into a side bag). Mirror `InventoryController`'s debounce. ### 6d. `IItemListDragHandler` @@ -295,8 +298,9 @@ _paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind( confidence (the contents grid proves the same `0x2100003D` base path), but load-bearing — if it fails, fix the importer/factory before continuing. - **Core — `EquipMaskTests`:** numeric pin of every member vs `acclient.h:3193`. -- **Core — `ClientObjectTableTests`:** `WieldItemOptimistic` sets equip + WielderId + container; `RollbackMove` - restores the pre-wield equip mask (the new snapshot field); outstanding-count across wield+move of the same item. +- **Core — `ClientObjectTableTests`:** `WieldItemOptimistic` sets equip + container (ContainerId-based; does + NOT write WielderId); `RollbackMove` restores the pre-wield equip mask (the new snapshot field); + outstanding-count across wield+move of the same item. - **Core.Net — `GameEventWiringTests`:** `WieldObject 0x0023` now calls `ConfirmMove` (an optimistic wield's snapshot clears on the echo). - **App — `PaperdollControllerTests`:** element-id→mask map matches the dump; populate shows the equipped item diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index fac6dff0..c5ba54c7 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -57,14 +57,18 @@ public sealed class PaperdollController : IItemListDragHandler { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield; - int index = 0; - foreach (var (element, mask) in SlotMap) + for (int i = 0; i < SlotMap.Length; i++) { - if (layout.FindElement(element) is not UiItemList list) { index++; continue; } + var (element, mask) = SlotMap[i]; + if (layout.FindElement(element) is not UiItemList list) continue; list.RegisterDragHandler(this); list.Cell.SourceKind = ItemDragSource.Equipment; - list.Cell.SlotIndex = index++; // a stable per-slot index (routing uses the list, not this) - list.Cell.EmptySprite = 0u; // TRANSPARENT empty slot (brainstorm decision; no doll behind it) + list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield + list.Cell.EmptySprite = 0u; // TRANSPARENT empty slot (brainstorm decision; no doll behind it in Slice 1) + // Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring + // 0x060011F9 / reject circle 0x060011F8 — the discrete-slot frames, NOT the inventory grid's + // insert-arrow 0x060011F7) are already wired by DatWidgetFactory when it built the UiItemList; + // no need to re-set them here. _slots.Add((mask, list)); } @@ -85,11 +89,16 @@ public sealed class PaperdollController : IItemListDragHandler private void OnObjectMoved(ClientObject o, uint from, uint to) { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } - /// The object is (or just became / ceased to be) the player's equipped gear. + /// The object belongs to the player (wielded gear or pack contents) — so a change to it may + /// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries + /// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always + /// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by + /// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved adds the from/to + /// player backstop for moves that transiently satisfy neither (e.g. unwield into a side bag). private bool Concerns(ClientObject o) { uint p = _playerGuid(); - return o.CurrentlyEquippedLocation != EquipMask.None || o.WielderId == p || o.ContainerId == p; + return o.WielderId == p || o.ContainerId == p; } public void Populate() diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index 526176bb..584eb7b2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -118,4 +118,30 @@ public class PaperdollControllerTests Bind(layout, new ClientObjectTable()); Assert.Equal(0u, lists[HeadSlot].Cell.EmptySprite); } + + [Fact] + public void Live_player_wield_repaints_the_slot() // event-driven: ObjectMoved → Concerns → Populate + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + Bind(layout, objects); // slots empty at bind + SeedPackItem(objects, 0xF01u, EquipMask.HeadWear); // a helm appears in the pack (not on the doll) + Assert.Equal(0u, lists[HeadSlot].Cell.ItemId); + objects.WieldItemOptimistic(0xF01u, Player, EquipMask.HeadWear); // player wields it → ObjectMoved(to=player) + Assert.Equal(0xF01u, lists[HeadSlot].Cell.ItemId); // the head slot repainted with the helm + } + + [Fact] + public void Live_npc_equip_does_not_appear_on_the_doll() // player-scoped: a remote creature's wielded item never leaks + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + Bind(layout, objects); + const uint npc = 0x60000001u; + objects.AddOrUpdate(new ClientObject + { + ObjectId = 0xF02u, WielderId = npc, CurrentlyEquippedLocation = EquipMask.HeadWear, + }); + Assert.Equal(0u, lists[HeadSlot].Cell.ItemId); // the NPC's helm is NOT on the player's doll + } }