diff --git a/docs/ISSUES.md b/docs/ISSUES.md index be9431f9..a012a94a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1619,14 +1619,14 @@ R5/R6 touches the action list. **Where:** ## #197 — Target-mode clicks: inventory grid cells bypass ItemInteractionController while the cursor says TargetValid -**Status:** OPEN +**Status:** DONE (Wave 3.3, 2026-07-11) **Severity:** MEDIUM **Filed:** 2026-07-02 **Component:** ui **Description:** Target-mode interception is per-call-site opt-in. Toolbar, paperdoll, and the main-pack cell consult `ItemInteractionController` first; `InventoryController.AddCell` grid cells wire `Clicked = () => SelectItem(guid)` (and bags to `OpenContainer`) with no target-mode check — while `CursorFeedbackController.ResolveUseTargetGuid` returns `slot.ItemId` for ANY occupied slot, so the cursor shows the valid-target bullseye over cells whose click just selects. -**Root cause / status:** Review finding (theme T3). Fix direction: one chokepoint — `UiItemSlot` (or UiRoot) offers the click to `ItemInteractionController.AcquireTarget` first while target mode is active, before the per-window `Clicked` callback. +**Root cause / status:** Fixed by typed `ItemInteractionController.OfferPrimaryClick`. Inventory contents/bags/main pack, paperdoll slots/body, toolbar, radar, and world picks offer the click before local fallback. Successful and rejected target attempts are consumed; only `NotActive` may select, open, or use. **Files:** `src/AcDream.App/UI/Layout/InventoryController.cs:307-308`, `src/AcDream.App/UI/UiItemSlot.cs:187-189`, `src/AcDream.App/UI/CursorFeedbackController.cs:240-254`. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index d20d3363..2bebd47e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -211,7 +211,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps. | AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` | | AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | -| AP-107 | Inventory grid/bag clicks bypass active target-mode acquisition while cursor resolution treats occupied cells as targets (#197) | `src/AcDream.App/UI/Layout/InventoryController.cs`; `UiItemSlot.cs` | Main pack/toolbar/paperdoll already opt in at their call sites | A valid-target cursor click selects/opens rather than consuming the target | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | +| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll omits Aetheria, authoritative upper-item priority, full `AutoWieldIsLegal`/dual-wield rules, and body-part interaction/highlighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, and optimistic wield work | Layered clothing, special slots, invalid drops, and doll-body clicks differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` | | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | | AP-110 | Remaining retained gameplay panels and world HUD are absent: combat/powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Existing panels cover the current M2 loop; Wave 10 lands each missing surface against authoritative state | Large portions of retail gameplay have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 99324a8f..f7950953 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -484,6 +484,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅, **Wave 3.1 unified selection** (Core `SelectionState`; AP-58 retired) ✅, and **Wave 3.2 pure item-use/drop policy** (`ItemInteractionPolicy`; AP-99 retired) ✅ are shipped. Build order from here: **finish Wave 3 activation router/cursors, then D.5.3 selected-object mana + stack entry/slider and the separate favorite-spell bars; ISSUES #146/#147/#197.** Each ☐ below gets its own focused oracle/spec/plan/gate. - **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio [--layout 0xNNNN | --dump ] [--screenshot ]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed. - **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct. +- **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained end-to-end matrix green. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars. diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md index cf9c9778..f38d8929 100644 --- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md +++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md @@ -371,16 +371,28 @@ controller tests. Full suite green before launch: 4,575 passed, 5 skipped. ### 3.3 Add one item primary-click chokepoint -- [ ] Define a controller-level activation router: target-mode acquisition first, +- [x] Define a controller-level activation router: target-mode acquisition first, then window-specific fallback (select/open/use). -- [ ] Return a typed result (`NotActive`, `ConsumedSuccess`, `ConsumedRejected`) so +- [x] Return a typed result (`NotActive`, `ConsumedSuccess`, `ConsumedRejected`) so both valid and rejected target attempts are consumed and cannot accidentally fall through to selection, double-click use, or container opening. -- [ ] Route toolbar, inventory grid, bags, paperdoll slots/body, main pack and world +- [x] Route toolbar, inventory grid, bags, paperdoll slots/body, main pack and world objects through it. Widgets expose events; they do not depend on game controllers. -- [ ] Add end-to-end tests for health-kit target use on self, inventory item, worn +- [x] Add end-to-end tests for health-kit target use on self, inventory item, worn item, and world entity, including invalid-target behavior. +**Implementation (2026-07-11):** `ItemInteractionController.OfferPrimaryClick` +is the sole typed target-mode offer. `ConsumedRejected` is intentionally terminal: +invalid clicks clear retail target mode but never fall through to selection, +container opening, or ordinary activation. Inventory contents/bags/main pack, +paperdoll equipment/body, toolbar items, radar blips, and world picks all use the +router; retained widgets remain controller-agnostic event sources. End-to-end +retained tests pin self targeting and rejected inventory/worn targets without +selection drift, while controller tests pin valid world/self and invalid target +results. The consumed target is retained for retail's 500 ms double-click window, +so the second Click/DoubleClick pair cannot reactivate or equip the rejected +object after target mode clears. Issue #197 and AP-107 are retired. + ### 3.4 Complete retail cursor precedence and catalog - [ ] Port every reachable branch of `ClientUISystem::UpdateCursorState`, not only diff --git a/memory/project_ui_architecture.md b/memory/project_ui_architecture.md index 076f8022..f8e137fc 100644 --- a/memory/project_ui_architecture.md +++ b/memory/project_ui_architecture.md @@ -163,3 +163,16 @@ The 2026-07-11 non-debug Release gate passed ordinary use/container activation, equip synchronization, and grounded world drop. No healing kit was available, so targeted healing was not live-tested; retain the automated compatible, invalid, and self-target coverage as the acceptance evidence for that branch. + +## 2026-07-11 primary-click ownership + +Every primary click that can name an object must first call +`ItemInteractionController.OfferPrimaryClick`. Its typed result is the contract: +`NotActive` permits the surface's ordinary select/open/use fallback; +`ConsumedSuccess` and `ConsumedRejected` are both terminal. Never let a rejected +target click fall through. Inventory cells and bags, main pack, paperdoll slots +and body, toolbar, radar, and world picking share this router; widgets continue +to expose callbacks without importing controllers. The router retains a consumed +target through the toolkit's 500 ms double-click window; otherwise UiRoot's +second Click plus DoubleClick could run ordinary activation after the first click +cleared target mode. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 03135cd0..3ff381fd 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -12069,11 +12069,9 @@ public sealed class GameWindow : IDisposable if (picked is uint guid) { - if (_itemInteractionController?.IsTargetModeActive == true) - { - _itemInteractionController.AcquireTarget(guid); + if (_itemInteractionController?.OfferPrimaryClick(guid) + is not null and not AcDream.App.UI.ItemPrimaryClickResult.NotActive) return; - } _selection.Select(guid, AcDream.Core.Selection.SelectionChangeSource.World); string label = DescribeLiveEntity(guid); diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 1d5f7053..b5e52583 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -3,6 +3,14 @@ using AcDream.Core.Items; namespace AcDream.App.UI; +/// Result of offering a primary click to active item-target mode. +public enum ItemPrimaryClickResult +{ + NotActive, + ConsumedSuccess, + ConsumedRejected, +} + /// /// Shared retail item interaction orchestrator. UI widgets route item clicks, /// target acquisition, and drag-out drops here instead of duplicating @@ -46,6 +54,7 @@ public sealed class ItemInteractionController : IDisposable }; private const long RetailUseThrottleMs = 200; + private const long RetailDoubleClickMs = 500; private readonly ClientObjectTable _objects; private readonly Func _playerGuid; @@ -66,6 +75,8 @@ public sealed class ItemInteractionController : IDisposable private readonly InteractionState _interactionState; private long _lastUseMs = long.MinValue / 2; + private uint _consumedPrimaryClickTarget; + private long _consumedPrimaryClickMs = long.MinValue / 2; private int _busyCount; private bool _disposed; @@ -142,12 +153,41 @@ public sealed class ItemInteractionController : IDisposable && TargetCompatible(source, targetGuid); } + /// + /// The single target-mode interception point for world and retained item + /// clicks. A rejected target is still consumed and must never fall through + /// to selection, container opening, or ordinary item activation. + /// + public ItemPrimaryClickResult OfferPrimaryClick(uint targetGuid) + { + if (!IsTargetModeActive) + { + long now = _nowMs(); + if (targetGuid != 0 + && targetGuid == _consumedPrimaryClickTarget + && now - _consumedPrimaryClickMs <= RetailDoubleClickMs) + return ItemPrimaryClickResult.ConsumedRejected; + _consumedPrimaryClickTarget = 0; + return ItemPrimaryClickResult.NotActive; + } + + bool accepted = AcquireTarget(targetGuid); + _consumedPrimaryClickTarget = targetGuid; + _consumedPrimaryClickMs = _nowMs(); + return accepted + ? ItemPrimaryClickResult.ConsumedSuccess + : ItemPrimaryClickResult.ConsumedRejected; + } + + public ItemPrimaryClickResult OfferSelfPrimaryClick() + => OfferPrimaryClick(_playerGuid()); + public bool ActivateItem(uint itemGuid) { if (itemGuid == 0) return false; if (IsTargetModeActive) - return AcquireTarget(itemGuid); + return OfferPrimaryClick(itemGuid) == ItemPrimaryClickResult.ConsumedSuccess; var item = _objects.Get(itemGuid); if (item is null) return false; @@ -316,6 +356,7 @@ public sealed class ItemInteractionController : IDisposable private void EnterTargetMode(uint sourceGuid) { + _consumedPrimaryClickTarget = 0; _interactionState.EnterUseItemOnTarget(sourceGuid); var name = _objects.Get(sourceGuid)?.Name; if (!string.IsNullOrWhiteSpace(name)) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 57ca0bff..b49c1ba2 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -295,7 +295,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; main.Clicked = () => { - if (_itemInteraction?.AcquireSelfTarget() == true) return; + if (_itemInteraction?.OfferSelfPrimaryClick() + is not null and not ItemPrimaryClickResult.NotActive) return; OpenContainer(p); }; main.DoubleClicked = () => _itemInteraction?.ActivateItem(p); @@ -332,11 +333,26 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed) cell.DragRejectSprite = 0x060011F8u; // red circle cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid); - if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); } - else cell.Clicked = () => SelectItem(guid); + if (isContainer) + { + cell.Clicked = () => HandlePrimaryClick(guid, () => OpenContainer(guid)); + SetCapacityBar(cell, guid); + } + else + { + cell.Clicked = () => HandlePrimaryClick(guid, () => SelectItem(guid)); + } list.AddItem(cell); } + private void HandlePrimaryClick(uint guid, Action fallback) + { + if (_itemInteraction?.OfferPrimaryClick(guid) + is not null and not ItemPrimaryClickResult.NotActive) + return; + fallback(); + } + private static void AddEmptyCell(UiItemList list) { var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() }; diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index 9f2c6de2..ac6b43f0 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -106,13 +106,12 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live list.Cell.Clicked = () => { - if (_itemInteraction?.IsTargetModeActive == true) - { - _itemInteraction.AcquireSelfTarget(); + uint itemId = list.Cell.ItemId; + if (itemId != 0 && _itemInteraction?.OfferPrimaryClick(itemId) + is not null and not ItemPrimaryClickResult.NotActive) return; - } - if (list.Cell.ItemId != 0) - _selection.Select(list.Cell.ItemId, SelectionChangeSource.Paperdoll); + if (itemId != 0) + _selection.Select(itemId, SelectionChangeSource.Paperdoll); }; list.Cell.DoubleClicked = () => { @@ -144,7 +143,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo // four-arrows — retail resolves target cursors off the SmartBox world // object only; UI hover is always pending. 2026-07-03 visual gate.) if (_dollViewport is UiViewport doll) - doll.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); }; + doll.Clicked = () => { _itemInteraction?.OfferSelfPrimaryClick(); }; var slotsBtnEl = layout.FindElement(0x100005BEu); if (slotsBtnEl is UiButton slotsBtn) diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 7c859348..7c4a5bb0 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -257,7 +257,13 @@ public sealed class RetailUiRuntime : IDisposable RadarController controller = RadarController.Bind( layout, _bindings.Radar.Snapshot, - guid => _bindings.Radar.Selection.Select(guid, SelectionChangeSource.Radar), + guid => + { + if (_bindings.Toolbar.ItemInteraction.OfferPrimaryClick(guid) + != ItemPrimaryClickResult.NotActive) + return; + _bindings.Radar.Selection.Select(guid, SelectionChangeSource.Radar); + }, setUiLocked: _bindings.Radar.SetUiLocked, datFont: _bindings.Assets.DefaultFont); RetailWindowFrame.Mount(Host.Root, radarRoot, _bindings.Assets.ResolveSprite, diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 81635251..b2a25e72 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -84,6 +84,32 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.UseWithTarget); } + [Fact] + public void PrimaryClickRouter_distinguishesInactiveSuccessAndConsumedRejection() + { + var h = new Harness(); + h.AddContained(0x50000A01u, item => + { + item.Useability = HealthKitUseability; + item.TargetType = (uint)ItemType.Creature; + }); + h.AddContained(0x50000A02u, item => item.Type = ItemType.Armor); + + Assert.Equal(ItemPrimaryClickResult.NotActive, + h.Controller.OfferPrimaryClick(Player)); + + h.Controller.ActivateItem(0x50000A01u); + Assert.Equal(ItemPrimaryClickResult.ConsumedRejected, + h.Controller.OfferPrimaryClick(0x50000A02u)); + Assert.False(h.Controller.IsTargetModeActive); + + h.Now += 200; + h.Controller.ActivateItem(0x50000A01u); + Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess, + h.Controller.OfferSelfPrimaryClick()); + Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget); + } + [Fact] public void SelfTarget_sendsUseWithTargetAndClearsTargetMode() { diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs index 07d5a34a..64b7d2ad 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs @@ -163,7 +163,7 @@ public sealed class RetailUiInteractionFlowTests return interaction; } - public void BindPaperdoll() + public void BindPaperdoll(ItemInteractionController? itemInteraction = null) => PaperdollController.Bind( Layout, Objects, @@ -171,7 +171,8 @@ public sealed class RetailUiInteractionFlowTests iconIds: static (_, _, _, _, _) => 0x1234u, selection: Selection, sendWield: (item, mask) => Wields.Add((item, mask)), - emptySlotSprite: 0x06004D20u); + emptySlotSprite: 0x06004D20u, + itemInteraction: itemInteraction); public RetailUiAutomationProbe Probe() => new(Root, Objects); @@ -259,6 +260,44 @@ public sealed class RetailUiInteractionFlowTests Assert.False(interaction.IsTargetModeActive); } + [Fact] + public void InventoryTargetUse_invalidInventoryItem_isConsumedWithoutSelectionFallback() + { + var h = new Harness(); + h.SeedHealthKit(slot: 0); + h.SeedHauberk(slot: 1); + var interaction = h.BindInventoryInteraction(); + var probe = h.Probe(); + + Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory)); + Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory)); + + Assert.Empty(h.UseWithTarget); + Assert.Empty(h.Wields); + Assert.False(interaction.IsTargetModeActive); + Assert.Equal(HealthKit, h.Selection.SelectedObjectId); + } + + [Fact] + public void InventoryTargetUse_invalidWornItem_isConsumedWithoutPaperdollSelectionFallback() + { + var h = new Harness(); + h.SeedHealthKit(slot: 0); + h.SeedHauberk(slot: 1); + h.Objects.MoveItem(Hauberk, Player, -1, HauberkMask); + var interaction = h.BindInventoryInteraction(); + h.BindPaperdoll(interaction); + var probe = h.Probe(); + + Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory)); + Assert.True(probe.ClickElement(SlotsButtonId)); + Assert.True(probe.ClickItem(Hauberk, ItemDragSource.Equipment)); + + Assert.Empty(h.UseWithTarget); + Assert.False(interaction.IsTargetModeActive); + Assert.Equal(HealthKit, h.Selection.SelectedObjectId); + } + [Fact] public void InventoryDragOutsideUi_routesThroughRootOutsideHookAndDropsToWorld() {