From 7983309d2340b7b0f04ff1c70db312e4b49e4a1e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Jul 2026 00:51:20 +0200 Subject: [PATCH] feat(ui): centralize retail selection state --- docs/architecture/acdream-architecture.md | 7 +- docs/architecture/code-structure.md | 7 +- .../retail-divergence-register.md | 5 +- docs/plans/2026-04-11-roadmap.md | 2 +- ...0-retail-toolbar-interaction-pseudocode.md | 5 +- ...026-07-10-retail-ui-fidelity-completion.md | 17 ++- memory/project_ui_architecture.md | 16 +++ src/AcDream.App/Plugins/AppPluginHost.cs | 9 +- src/AcDream.App/Program.cs | 7 +- src/AcDream.App/Rendering/GameWindow.cs | 67 +++++------ src/AcDream.App/Studio/FixtureProvider.cs | 3 + src/AcDream.App/UI/InteractionState.cs | 54 +++++++++ .../UI/ItemInteractionController.cs | 35 ++++-- .../UI/Layout/InventoryController.cs | 38 +++++-- .../UI/Layout/PaperdollController.cs | 37 +++++- .../UI/Layout/SelectedObjectController.cs | 27 +++-- src/AcDream.App/UI/RetailUiRuntime.cs | 18 +-- src/AcDream.App/UI/TargetIndicatorPanel.cs | 2 +- src/AcDream.Core/Selection/SelectionState.cs | 106 ++++++++++++++++++ .../IPluginHost.cs | 1 + .../ISelectionService.cs | 19 ++++ .../UI/InteractionStateTests.cs | 32 ++++++ .../UI/ItemInteractionControllerTests.cs | 4 + .../UI/Layout/InventoryControllerTests.cs | 34 +++++- .../UI/Layout/InventoryFrameImportProbe.cs | 1 + .../UI/Layout/PaperdollControllerTests.cs | 22 +++- .../Layout/SelectedObjectControllerTests.cs | 19 ++-- .../UI/RetailUiInteractionFlowTests.cs | 3 + .../Plugins/PluginLoaderTests.cs | 2 + .../Selection/SelectionStateTests.cs | 100 +++++++++++++++++ 30 files changed, 591 insertions(+), 108 deletions(-) create mode 100644 src/AcDream.App/UI/InteractionState.cs create mode 100644 src/AcDream.Core/Selection/SelectionState.cs create mode 100644 src/AcDream.Plugin.Abstractions/ISelectionService.cs create mode 100644 tests/AcDream.App.Tests/UI/InteractionStateTests.cs create mode 100644 tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 0ca8fd7a..befe11d3 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -102,7 +102,11 @@ retained gameplay tree, LayoutDesc importer, window runtime, and panel controllers. Neither presentation stack owns independent game-state truth. Plugins register retained gameplay markup through the BCL-only `AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or ImGui -assemblies. +assemblies. Core `SelectionState` is the sole selected-object owner for world, +radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins; +`IPluginHost.Selection` exposes that same state and retail-style old/new callback. +Temporary pointer modes are separate App orchestration in `InteractionState` and +must never become a competing selection owner. Every retained gameplay window has a typed `RetailWindowHandle` and at most one `IRetainedPanelController` lifecycle owner. Multi-controller windows use @@ -164,6 +168,7 @@ src/ IPluginHost.cs -> done IGameState.cs -> done IEvents.cs -> done + ISelectionService.cs -> done AcDream.App/ Layer 1 + Layer 4 wiring Rendering/ diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 288efd06..9483ed79 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -214,8 +214,9 @@ What `GameWindow` loses: `LiveSessionController`. - `_entitiesByServerGuid` + per-entity dictionaries + ServerGuid↔Id translation → moved into `LiveEntityRuntime`. -- `WorldPicker` + `_selectedGuid` + `SendUse` / `SendPickUp` → moved - into `SelectionInteractionController`. +- `WorldPicker` + selection-driven `SendUse` / `SendPickUp` orchestration → moved + into `SelectionInteractionController`; Core `SelectionState` remains the + already-shipped session owner and is injected into that controller. - Per-frame draw orchestration → moved into `RenderFrameOrchestrator`. The eventual `GameEntity` aggregation (target state described in @@ -289,7 +290,7 @@ All four M1 demo targets must still work. ### Step 4 — `SelectionInteractionController` -**Scope:** Extract `WorldPicker`, `_selectedGuid`, `SendUse`, +**Scope:** Extract `WorldPicker`, the Core `SelectionState` consumers, `SendUse`, `SendPickUp`, and the `InputAction.Select*` / `UseSelected` / `SelectionPickUp` switch cases into one controller. Depends on Step 3 (uses `LiveEntityRuntime`). diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 90e9144d..8ad8619e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -104,7 +104,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 99 active rows +## 3. Documented approximation (AP) — 98 active rows Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -162,8 +162,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps. | AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` | | AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize | | AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` | -| AP-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail keying (`item.itemID == openContainerId / selectedItemId` per `UpdateOpenContainerIndicator 0x004e3070` / `ItemList_SetSelectedItem 0x004e2fe0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ItemList_SetSelectedItem` 0x004e2fe0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 | -| AP-58 | Inventory item **selection is panel-local + visual-only** — clicking a grid item moves the green square but does not wire to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`SelectItem`) | The selected-object bar + 3D-world selection wiring is deferred to the selection follow-up; the panel-local square is the correct first step. | Selecting an inventory item doesn't populate the selected-object strip as retail does — functional gap deferred. | `gmInventoryUI`/`gmBackpackUI` item-select → `ClientUISystem::SetSelectedObject`; `SelectedObjectController` | +| AP-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by `_openContainer` plus Core `SelectionState.SelectedObjectId`, reproducing the retail keying (`item.itemID == openContainerId / selectedID` per `UpdateOpenContainerIndicator 0x004e3070` / `SetSelectedObject 0x0058c2e0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ACCWeenieObject::SetSelectedObject` 0x0058c2e0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 | | AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` | | AP-60 | Inventory drag **`OnDragLift` is a no-op** + the source cell is **not dimmed**: retail dims the lifted item's source cell (`RecvNotice_ItemListBeginDrag`); acdream leaves the item in place during the drag + the floating ghost. | `src/AcDream.App/UI/Layout/InventoryController.cs` | Cosmetic only — the dragged item is still unambiguously identifiable; dimming the source requires tracking the source cell reference across drag events, deferred to a polish pass. | A drag in progress doesn't visually mark its origin — cosmetic only, no functional effect. | `RecvNotice_ItemListBeginDrag` acclient_2013_pseudo_c.txt | | AP-61 | Drop on a **CLOSED side bag is advisory-accept**: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`) | A drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Faithful only when the bag has been opened (then `GetContents` is populated). The server's `0x00A0` + optimistic rollback is the authoritative safety net. | A drop into a closed-but-full bag shows green → brief flicker → snap-back instead of retail's pre-emptive red circle. | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 422a971a..2a1db2b5 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -481,7 +481,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. - **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2`→`0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 1–9** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject` → `0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay` → `ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout. - **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`. -- **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)** ✅, and **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅ are shipped. Build order from here: **(d) finish the bar — D.5.3 remaining (selected-object meter/mana, stack entry/slider) + spell shortcuts + the selection→selected-object-bar wiring (AP-58); ISSUES #146/#147/#148.** Each ☐ below gets its own brainstorm → spec → plan. +- **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`) ✅, and **Wave 3.1 unified selection** (Core `SelectionState`; AP-58 retired) ✅ are shipped. Build order from here: **finish Wave 3 item policy/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 — 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. diff --git a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md index ff16f898..57261aec 100644 --- a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md +++ b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md @@ -979,8 +979,9 @@ implementation patch. is not a faithful `UseObject` port: it omits ready/vendor/trade/confirmations, the complete `DetermineUseResult` classification, container post-actions, `AttemptPlaceIn3D`, and retail busy semantics. -6. Selection remains owned by `GameWindow._selectedGuid`; the previously documented - `Core.SelectionState` file is absent in this worktree. +6. **Resolved 2026-07-11:** selection is owned by Core `SelectionState` and shared + by world/radar/inventory/paperdoll/toolbar/plugins; the former + `GameWindow._selectedGuid` and inventory-local selected item are gone. 7. Current cursor catalog exposes only `0x27..0x29`. The reachable global catalog also includes `0x01..0x06` and `0x0A..0x0F`. 8. Current cursor resolution uses a modern static priority chain. Retail instead 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 eeba6b46..d3aa968a 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 @@ -316,15 +316,24 @@ chrome and content together; full suite and user visual gate. ### 3.1 Centralize selection -- [ ] Add Core `SelectionState` with selected guid, previous guid, reason/source, +- [x] Add Core `SelectionState` with selected guid, previous guid, reason/source, and one change event. -- [ ] Route world pick, radar click, inventory selection, paperdoll selection, +- [x] Route world pick, radar click, inventory selection, paperdoll selection, toolbar selected strip, target indicator, examine/use, and plugins through it. -- [ ] Delete panel-local competing selection state except transient hover/row focus. -- [ ] Add one App interaction-state owner for `None`, `Use`, `Examine`, and +- [x] Delete panel-local competing selection state except transient hover/row focus. +- [x] Add one App interaction-state owner for `None`, `Use`, `Examine`, and `UseItemOnTarget(sourceGuid)`; Core selection remains session truth while target mode remains UI orchestration state. +**Implementation (2026-07-11):** Core `SelectionState` ports +`ACCWeenieObject::SetSelectedObject @ 0x0058C2E0`, including deduplication, +previous/previous-valid tracking, object-removal clear, and plugin callbacks. +World pick, radar, combat targeting, inventory, paperdoll slots, the selected +toolbar strip, target indicator, use/pickup, PreviousSelection, and the new +`IPluginHost.Selection` contract share that owner. `InteractionState` separately +owns `None`/`Use`/`Examine`/`UseItemOnTarget`; `ItemInteractionController` now +projects its pending source from that state. AP-58 retired. + ### 3.2 Port item-use/drop legality into Core - [ ] Translate `ItemHolder::UseObject`, `DetermineUseResult`, and diff --git a/memory/project_ui_architecture.md b/memory/project_ui_architecture.md index 2aaa8635..5c26c368 100644 --- a/memory/project_ui_architecture.md +++ b/memory/project_ui_architecture.md @@ -120,3 +120,19 @@ window mounts, toolbar digit and inventory-cell asset lookup, plugin mounts, cursor feedback, persistence, automation, and retained tick/draw/restore/dispose paths live behind the runtime. The live Debug gate passed across two graceful restart cycles. Do not add panel mount bodies back to `GameWindow`. + +## 2026-07-11 selection and interaction ownership + +`AcDream.Core.Selection.SelectionState` is the only selected-object truth. It +ports retail `ACCWeenieObject::SetSelectedObject`: deduplicate the current guid, +remember previous and previous-valid guids, commit before firing one typed +transition, and clear with the removed object retained as previous. World pick, +radar, combat targeting, inventory, paperdoll slots, toolbar selected-object +presentation, target indicator, use/pickup, PreviousSelection, and plugins all +read or write this same owner. Plugins use `IPluginHost.Selection`; plugin event +failures are isolated from the client and other plugins. + +`AcDream.App.UI.InteractionState` separately owns temporary pointer orchestration: +`None`, `Use`, `Examine`, or `UseItemOnTarget(sourceGuid)`. Item target mode is a +projection of this state, not a second selected-object field. Keep selection in +Core and interaction modes in App when completing Wave 3. diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index 5b06e67e..bfabab86 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -4,16 +4,23 @@ namespace AcDream.App.Plugins; public sealed class AppPluginHost : IPluginHost { - public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui) + public AppPluginHost( + IPluginLogger log, + IGameState state, + IEvents events, + ISelectionService selection, + IUiRegistry ui) { Log = log; State = state; Events = events; + Selection = selection; Ui = ui; } public IPluginLogger Log { get; } public IGameState State { get; } public IEvents Events { get; } + public ISelectionService Selection { get; } public IUiRegistry Ui { get; } } diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 9e48adbb..5aa89c32 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -31,8 +31,10 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir); var worldGameState = new AcDream.Core.Plugins.WorldGameState(); var worldEvents = new AcDream.Core.Plugins.WorldEvents(); +var selection = new AcDream.Core.Selection.SelectionState(); var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); -var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry); +var host = new AppPluginHost( + new SerilogAdapter(Log.Logger), worldGameState, worldEvents, selection, uiRegistry); var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins"); Log.Information("scanning plugins in {PluginsDir}", pluginsDir); @@ -65,7 +67,8 @@ try catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } } - using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry); + using var window = new GameWindow( + runtimeOptions, worldGameState, worldEvents, selection, uiRegistry); window.Run(); } finally diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2bbcf765..3c2f8e0d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -16,6 +16,7 @@ public sealed class GameWindow : IDisposable private readonly string _datDir; private readonly WorldGameState _worldGameState; private readonly WorldEvents _worldEvents; + private readonly AcDream.Core.Selection.SelectionState _selection; private IWindow? _window; private GL? _gl; private IInputContext? _input; @@ -1032,24 +1033,6 @@ public sealed class GameWindow : IDisposable /// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals. /// private readonly Dictionary _lastSpawnByGuid = new(); - // Current selection: written by Q-cycle (combat) and LMB click (interact); cleared on entity despawn. - private uint? _selectedGuid; - /// Fires when the selected world object changes (retail gmToolbarUI selection-change event, - /// acclient_2013_pseudo_c.txt:198635). Private: only the internal SelectedObjectController subscribes. - private event Action? SelectionChanged; - /// Currently-selected world object guid. The setter fires only on - /// an actual change (dedup), so all writes go through here; reads may use the field directly. - private uint? SelectedGuid - { - get => _selectedGuid; - set - { - if (_selectedGuid == value) return; - _selectedGuid = value; - SelectionChanged?.Invoke(value); - } - } - // B.6/B.7 (2026-05-16): pending close-range action that will be fired // once the local auto-walk overlay reports arrival (body has finished // rotating to face the target). Only set for close-range Use/PickUp; @@ -1085,13 +1068,18 @@ public sealed class GameWindow : IDisposable private int _liveAnimRejectSingleFrame; private int _liveAnimRejectPartFrames; - public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents, + public GameWindow( + AcDream.App.RuntimeOptions options, + WorldGameState worldGameState, + WorldEvents worldEvents, + AcDream.Core.Selection.SelectionState selection, AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); _datDir = options.DatDir; _worldGameState = worldGameState; _worldEvents = worldEvents; + _selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _uiRegistry = uiRegistry; SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); @@ -1525,13 +1513,13 @@ public sealed class GameWindow : IDisposable // B.7 Vivid Target Indicator — corner-triangle highlight // around the currently-selected entity. Delegates pull // live state from this GameWindow instance every frame: - // - selected guid → _selectedGuid (set by PickAndStoreSelection) + // - selected guid → shared SelectionState // - entity resolver → position from _entitiesByServerGuid + // itemType from ClientObjectTable (Objects) + last spawn // - camera → _cameraController.Active or (zero) when not // yet ready, in which case the panel bails on viewport==0. _targetIndicator = new AcDream.App.UI.TargetIndicatorPanel( - selectedGuidProvider: () => _selectedGuid, + selectedGuidProvider: () => _selection.SelectedObjectId, entityResolver: guid => { if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) @@ -2078,7 +2066,7 @@ public sealed class GameWindow : IDisposable playerGuid: () => _playerServerGuid, playerYawRadians: () => _playerController?.Yaw ?? 0f, playerCellId: () => _playerController?.CellId ?? 0u, - selectedGuid: () => _selectedGuid, + selectedGuid: () => _selection.SelectedObjectId, coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar, uiLocked: () => _persistedGameplay.LockUI); var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); @@ -2103,7 +2091,7 @@ public sealed class GameWindow : IDisposable AcDream.UI.Abstractions.NullCommandBus.Instance), Radar: new AcDream.App.UI.RadarRuntimeBindings( radarSnapshotProvider.BuildSnapshot, - guid => SelectedGuid = guid, + _selection, SetRetailUiLocked), Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings( Objects, @@ -2114,8 +2102,7 @@ public sealed class GameWindow : IDisposable _itemInteractionController, (index, guid) => _liveSession?.SendAddShortcut(index, guid), index => _liveSession?.SendRemoveShortcut(index), - handler => SelectionChanged += handler, - handler => SelectionChanged -= handler, + _selection, handler => Combat.HealthChanged += handler, handler => Combat.HealthChanged -= handler, IsHealthBarTarget, @@ -2137,7 +2124,8 @@ public sealed class GameWindow : IDisposable (item, container, placement) => _liveSession?.SendPutItemInContainer(item, container, placement), (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), - _itemInteractionController), + _itemInteractionController, + _selection), Cursor: new AcDream.App.UI.RetailUiCursorBindings( cursorFeedbackController, retailCursorManager), @@ -4478,8 +4466,12 @@ public sealed class GameWindow : IDisposable _remoteLastMove.Remove(serverGuid); _entitiesByServerGuid.Remove(serverGuid); _lastSpawnByGuid.Remove(serverGuid); - if (_selectedGuid == serverGuid) - SelectedGuid = null; + if (_selection.SelectedObjectId == serverGuid) + { + _selection.Clear( + AcDream.Core.Selection.SelectionChangeSource.System, + AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); + } // A7 indoor lighting: release this entity's owned lights on EVERY // removal, including the respawn-dedup path (former logDelete=false). @@ -11874,6 +11866,10 @@ public sealed class GameWindow : IDisposable SelectClosestCombatTarget(showToast: true); break; + case AcDream.UI.Abstractions.Input.InputAction.SelectionPreviousSelection: + _selection.SelectPrevious(); + break; + case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat: ToggleLiveCombatMode(); break; @@ -11903,7 +11899,7 @@ public sealed class GameWindow : IDisposable break; case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp: - if (_selectedGuid is uint pickupTarget) + if (_selection.SelectedObjectId is uint pickupTarget) SendPickUp(pickupTarget); else _debugVm?.AddToast("Nothing selected"); @@ -11979,7 +11975,7 @@ public sealed class GameWindow : IDisposable private uint? GetSelectedOrClosestCombatTarget() { - if (_selectedGuid is { } selected && IsLiveCreatureTarget(selected)) + if (_selection.SelectedObjectId is { } selected && IsLiveCreatureTarget(selected)) return selected; return SelectClosestCombatTarget(showToast: false); @@ -12066,7 +12062,7 @@ public sealed class GameWindow : IDisposable return; } - SelectedGuid = guid; + _selection.Select(guid, AcDream.Core.Selection.SelectionChangeSource.World); string label = DescribeLiveEntity(guid); Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); // B.7 (2026-05-15): one-shot per-pick diagnostic so we can @@ -12107,7 +12103,7 @@ public sealed class GameWindow : IDisposable private void UseCurrentSelection() { - if (_selectedGuid is not uint sel) + if (_selection.SelectedObjectId is not uint sel) { _debugVm?.AddToast("Nothing selected"); return; @@ -12503,7 +12499,10 @@ public sealed class GameWindow : IDisposable bestGuid = guid; } - SelectedGuid = bestGuid; + if (bestGuid is { } selection) + _selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard); + else + _selection.Clear(AcDream.Core.Selection.SelectionChangeSource.Keyboard); if (bestGuid is { } selected) { string label = DescribeLiveEntity(selected); @@ -13592,6 +13591,8 @@ public sealed class GameWindow : IDisposable _retailUiRuntime?.Dispose(); _retailUiRuntime = null; _uiHost = null; + _itemInteractionController?.Dispose(); + _itemInteractionController = null; // Phase A.1: join the streamer worker thread before tearing down GL // state. The worker may still be processing a load job that references diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index 360c4fb3..3d8e130f 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -113,6 +113,7 @@ public static class FixtureProvider uint mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001C9u); var iconIds = MakeIconIds(stack); + var selection = new AcDream.Core.Selection.SelectionState(); InventoryController.Bind( layout, @@ -120,6 +121,7 @@ public static class FixtureProvider playerGuid: () => SampleData.PlayerGuid, iconIds: iconIds, strength: () => 100, + selection: selection, datFont: stack.VitalsDatFont, contentsEmptySprite: contentsEmpty, sideBagEmptySprite: sideBagEmpty, @@ -133,6 +135,7 @@ public static class FixtureProvider objects, playerGuid: () => SampleData.PlayerGuid, iconIds: iconIds, + selection: selection, sendWield: null, // no live session in the studio emptySlotSprite: contentsEmpty, datFont: stack.VitalsDatFont); diff --git a/src/AcDream.App/UI/InteractionState.cs b/src/AcDream.App/UI/InteractionState.cs new file mode 100644 index 00000000..49254dd7 --- /dev/null +++ b/src/AcDream.App/UI/InteractionState.cs @@ -0,0 +1,54 @@ +namespace AcDream.App.UI; + +public enum InteractionModeKind +{ + None, + Use, + Examine, + UseItemOnTarget, +} + +public readonly record struct InteractionMode( + InteractionModeKind Kind, + uint SourceObjectId = 0) +{ + public static InteractionMode None => new(InteractionModeKind.None); +} + +public readonly record struct InteractionModeTransition( + InteractionMode Previous, + InteractionMode Current); + +/// +/// Single App-layer owner for retail's pointer interaction mode. Core selection +/// remains session truth; this state represents temporary UI orchestration. +/// +public sealed class InteractionState +{ + public InteractionMode Current { get; private set; } = InteractionMode.None; + + public event Action? Changed; + + public bool EnterUse() => Set(new InteractionMode(InteractionModeKind.Use)); + + public bool EnterExamine() => Set(new InteractionMode(InteractionModeKind.Examine)); + + public bool EnterUseItemOnTarget(uint sourceObjectId) + { + if (sourceObjectId == 0) + throw new ArgumentOutOfRangeException(nameof(sourceObjectId)); + return Set(new InteractionMode(InteractionModeKind.UseItemOnTarget, sourceObjectId)); + } + + public bool Clear() => Set(InteractionMode.None); + + private bool Set(InteractionMode mode) + { + if (Current == mode) + return false; + InteractionMode previous = Current; + Current = mode; + Changed?.Invoke(new InteractionModeTransition(previous, mode)); + return true; + } +} diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 99f0eb0f..6ad1cb1b 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -8,7 +8,7 @@ namespace AcDream.App.UI; /// target acquisition, and drag-out drops here instead of duplicating /// ItemHolder::UseObject fragments in each panel. /// -public sealed class ItemInteractionController +public sealed class ItemInteractionController : IDisposable { private static readonly EquipMask[] AutoEquipOrder = { @@ -55,8 +55,10 @@ public sealed class ItemInteractionController private readonly Action? _sendWield; private readonly Action? _sendDrop; private readonly Action? _toast; + private readonly InteractionState _interactionState; private long _lastUseMs = long.MinValue / 2; + private bool _disposed; public ItemInteractionController( ClientObjectTable objects, @@ -66,7 +68,8 @@ public sealed class ItemInteractionController Action? sendWield, Action? sendDrop, Func? nowMs = null, - Action? toast = null) + Action? toast = null, + InteractionState? interactionState = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -76,15 +79,23 @@ public sealed class ItemInteractionController _sendDrop = sendDrop; _nowMs = nowMs ?? (() => Environment.TickCount64); _toast = toast; + _interactionState = interactionState ?? new InteractionState(); + _interactionState.Changed += OnInteractionModeChanged; } public event Action? StateChanged; public uint PlayerGuid => _playerGuid(); - public uint PendingSourceItem { get; private set; } + public InteractionState InteractionState => _interactionState; - public bool IsTargetModeActive => PendingSourceItem != 0; + public uint PendingSourceItem + => _interactionState.Current is { Kind: InteractionModeKind.UseItemOnTarget } mode + ? mode.SourceObjectId + : 0u; + + public bool IsTargetModeActive + => _interactionState.Current.Kind == InteractionModeKind.UseItemOnTarget; public bool IsPendingSource(uint itemGuid) => itemGuid != 0 && itemGuid == PendingSourceItem; @@ -188,8 +199,7 @@ public sealed class ItemInteractionController private void EnterTargetMode(uint sourceGuid) { - PendingSourceItem = sourceGuid; - StateChanged?.Invoke(); + _interactionState.EnterUseItemOnTarget(sourceGuid); var name = _objects.Get(sourceGuid)?.Name; if (!string.IsNullOrWhiteSpace(name)) _toast?.Invoke($"Choose a target for the {name}"); @@ -197,8 +207,17 @@ public sealed class ItemInteractionController private void ClearTargetMode() { - PendingSourceItem = 0; - StateChanged?.Invoke(); + _interactionState.Clear(); + } + + private void OnInteractionModeChanged(InteractionModeTransition _) + => StateChanged?.Invoke(); + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _interactionState.Changed -= OnInteractionModeChanged; } private bool ConsumeUseThrottle() diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 3aab285b..57ca0bff 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -2,6 +2,7 @@ using System; using System.Numerics; using AcDream.App.UI; using AcDream.Core.Items; +using AcDream.Core.Selection; namespace AcDream.App.UI.Layout; @@ -53,7 +54,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f); private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid - private uint _selectedItem; // 0 = none; the panel-wide selected item (green square) + private readonly SelectionState _selection; private readonly Action? _sendUse; private readonly Action? _sendNoLongerViewing; private readonly Action? _sendPutItemInContainer; // (item, container, placement) @@ -70,6 +71,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo Func playerGuid, Func iconIds, Func strength, + SelectionState selection, Func? ownerName, UiDatFont? datFont, uint contentsEmptySprite, @@ -90,6 +92,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _sendNoLongerViewing = sendNoLongerViewing; _sendPutItemInContainer = sendPutItemInContainer; _itemInteraction = itemInteraction; + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); WindowChromeController.BindCloseButton(layout, onClose); @@ -152,8 +155,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo // Rebuild on any change to the player's possessions. _objects.ObjectAdded += OnObjectChanged; _objects.ObjectMoved += OnObjectMoved; - _objects.ObjectRemoved += OnObjectChanged; + _objects.ObjectRemoved += OnObjectRemoved; _objects.ObjectUpdated += OnObjectChanged; + _selection.Changed += OnSelectionChanged; if (_itemInteraction is not null) _itemInteraction.StateChanged += OnInteractionStateChanged; @@ -184,6 +188,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo Func playerGuid, Func iconIds, Func strength, + SelectionState selection, UiDatFont? datFont, Func? ownerName = null, uint contentsEmptySprite = 0u, @@ -194,14 +199,27 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo Action? sendPutItemInContainer = null, ItemInteractionController? itemInteraction = null, Action? onClose = null) - => new InventoryController(layout, objects, playerGuid, iconIds, strength, ownerName, datFont, + => new InventoryController(layout, objects, playerGuid, iconIds, strength, selection, + ownerName, datFont, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, - sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose); + sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, + onClose); private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } + private void OnObjectRemoved(ClientObject o) + { + if (_selection.SelectedObjectId == o.ObjectId) + { + _selection.Clear( + SelectionChangeSource.System, + SelectionChangeReason.SelectedObjectRemoved); + } + if (Concerns(o)) Populate(); + } private void OnObjectMoved(ClientObject o, uint from, uint to) { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } private void OnInteractionStateChanged() => ApplyIndicators(); + private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators(); /// True if the object is in (or wielded by) the player — i.e. a rebuild is warranted. private bool Concerns(ClientObject o) @@ -402,8 +420,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo private void SelectItem(uint guid) { if (guid == 0) return; - _selectedItem = guid; - ApplyIndicators(); + _selection.Select(guid, SelectionChangeSource.Inventory); } /// Open a container (side bag or main pack): it becomes both the selected item and the @@ -413,7 +430,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo private void OpenContainer(uint guid) { if (guid == 0) return; - _selectedItem = guid; // the container cell is also the selected item + _selection.Select(guid, SelectionChangeSource.Inventory); uint open = EffectiveOpen(); if (guid == open) { ApplyIndicators(); return; } // already open — just move the square @@ -444,7 +461,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo var cell = list.GetItem(i); if (cell is null) continue; bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true; - cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem && !pendingTargetSource; + cell.Selected = cell.ItemId != 0 + && cell.ItemId == _selection.SelectedObjectId + && !pendingTargetSource; cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open; } } @@ -533,8 +552,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _disposed = true; _objects.ObjectAdded -= OnObjectChanged; _objects.ObjectMoved -= OnObjectMoved; - _objects.ObjectRemoved -= OnObjectChanged; + _objects.ObjectRemoved -= OnObjectRemoved; _objects.ObjectUpdated -= OnObjectChanged; + _selection.Changed -= OnSelectionChanged; if (_itemInteraction is not null) _itemInteraction.StateChanged -= OnInteractionStateChanged; } diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index bc66c1b2..9f2c6de2 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using AcDream.App.UI; using AcDream.Core.Items; +using AcDream.Core.Selection; namespace AcDream.App.UI.Layout; @@ -76,6 +77,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo private readonly Func _iconIds; private readonly Action? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A private readonly ItemInteractionController? _itemInteraction; + private readonly SelectionState _selection; private readonly List<(EquipMask Mask, UiItemList List)> _slots = new(); // ── Slots-toggle state ──────────────────────────────────────────────────────────────────────── @@ -86,11 +88,13 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo private PaperdollController( ImportedLayout layout, ClientObjectTable objects, Func playerGuid, - Func iconIds, Action? sendWield, + Func iconIds, SelectionState selection, + Action? sendWield, uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction) { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield; _itemInteraction = itemInteraction; + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); for (int i = 0; i < SlotMap.Length; i++) { @@ -100,7 +104,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo list.Cell.SourceKind = ItemDragSource.Equipment; list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live - list.Cell.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); }; + list.Cell.Clicked = () => + { + if (_itemInteraction?.IsTargetModeActive == true) + { + _itemInteraction.AcquireSelfTarget(); + return; + } + if (list.Cell.ItemId != 0) + _selection.Select(list.Cell.ItemId, SelectionChangeSource.Paperdoll); + }; list.Cell.DoubleClicked = () => { if (list.Cell.ItemId != 0) @@ -119,6 +132,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo _objects.ObjectMoved += OnObjectMoved; _objects.ObjectRemoved += OnObjectChanged; _objects.ObjectUpdated += OnObjectChanged; + _selection.Changed += OnSelectionChanged; // ── Slots-toggle wiring ─────────────────────────────────────────────────────────────────── foreach (var id in ArmorSlotElementIds) @@ -154,14 +168,18 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo public static PaperdollController Bind( ImportedLayout layout, ClientObjectTable objects, Func playerGuid, - Func iconIds, Action? sendWield = null, + Func iconIds, SelectionState selection, + Action? sendWield = null, uint emptySlotSprite = 0u, UiDatFont? datFont = null, ItemInteractionController? itemInteraction = null) - => new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont, itemInteraction); + => new PaperdollController( + layout, objects, playerGuid, iconIds, selection, sendWield, emptySlotSprite, + datFont, itemInteraction); private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } private void OnObjectMoved(ClientObject o, uint from, uint to) { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } + private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators(); /// 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 @@ -196,6 +214,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects); list.Cell.SetItem(worn.ObjectId, tex); } + ApplySelectionIndicators(); + } + + private void ApplySelectionIndicators() + { + foreach (var (_, list) in _slots) + { + list.Cell.Selected = list.Cell.ItemId != 0 + && list.Cell.ItemId == _selection.SelectedObjectId; + } } private EquipMask MaskFor(UiItemList list) @@ -251,5 +279,6 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo _objects.ObjectMoved -= OnObjectMoved; _objects.ObjectRemoved -= OnObjectChanged; _objects.ObjectUpdated -= OnObjectChanged; + _selection.Changed -= OnSelectionChanged; } } diff --git a/src/AcDream.App/UI/Layout/SelectedObjectController.cs b/src/AcDream.App/UI/Layout/SelectedObjectController.cs index f7ba9169..a555f739 100644 --- a/src/AcDream.App/UI/Layout/SelectedObjectController.cs +++ b/src/AcDream.App/UI/Layout/SelectedObjectController.cs @@ -1,6 +1,7 @@ using System; using System.Numerics; using AcDream.App.UI; +using AcDream.Core.Selection; namespace AcDream.App.UI.Layout; @@ -82,7 +83,7 @@ public sealed class SelectedObjectController : IRetainedPanelController private readonly Func _hasHealth; private readonly Func _stackSize; private readonly Action _sendQueryHealth; - private readonly Action> _unsubscribeSelectionChanged; + private readonly SelectionState _selection; private readonly Action> _unsubscribeHealthChanged; // ── Live state (read by closures on the per-frame draw path) ──────────── @@ -96,8 +97,7 @@ public sealed class SelectedObjectController : IRetainedPanelController private SelectedObjectController( ImportedLayout layout, - Action> subscribeSelectionChanged, - Action> unsubscribeSelectionChanged, + SelectionState selection, Action> subscribeHealthChanged, Action> unsubscribeHealthChanged, Func isHealthTarget, @@ -114,7 +114,7 @@ public sealed class SelectedObjectController : IRetainedPanelController _hasHealth = hasHealth; _stackSize = stackSize; _sendQueryHealth = sendQueryHealth; - _unsubscribeSelectionChanged = unsubscribeSelectionChanged; + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _unsubscribeHealthChanged = unsubscribeHealthChanged; // Find elements — silently skip absent ones (partial/test layouts). @@ -171,8 +171,10 @@ public sealed class SelectedObjectController : IRetainedPanelController } // Register the handlers LAST so the initial state is fully set up first. - subscribeSelectionChanged(OnSelectionChanged); + _selection.Changed += OnSelectionTransition; subscribeHealthChanged(OnHealthChanged); + if (_selection.SelectedObjectId is { } initial) + ApplySelection(initial); } /// @@ -180,8 +182,7 @@ public sealed class SelectedObjectController : IRetainedPanelController /// Port of retail gmToolbarUI::HandleSelectionChanged + RecvNotice_UpdateObjectHealth. /// /// Imported toolbar layout (LayoutDesc 0x21000016). - /// Called once with - /// (typical host: h => SelectionChanged += h). + /// The single Core selected-object owner. /// Called once with /// (typical host: h => Combat.HealthChanged += h) — drives meter visibility. /// Returns true for guids that may show a health meter @@ -195,8 +196,7 @@ public sealed class SelectedObjectController : IRetainedPanelController /// Dat font for the name label; null = debug bitmap font fallback. public static SelectedObjectController Bind( ImportedLayout layout, - Action> subscribeSelectionChanged, - Action> unsubscribeSelectionChanged, + SelectionState selection, Action> subscribeHealthChanged, Action> unsubscribeHealthChanged, Func isHealthTarget, @@ -207,7 +207,7 @@ public sealed class SelectedObjectController : IRetainedPanelController Action sendQueryHealth, UiDatFont? datFont) => new SelectedObjectController( - layout, subscribeSelectionChanged, unsubscribeSelectionChanged, + layout, selection, subscribeHealthChanged, unsubscribeHealthChanged, isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont); @@ -215,7 +215,7 @@ public sealed class SelectedObjectController : IRetainedPanelController /// Port of gmToolbarUI::HandleSelectionChanged (:198635): /// clear-then-populate the selected-object strip on any selection change. /// - public void OnSelectionChanged(uint? guid) + private void ApplySelection(uint? guid) { // ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0) // + SetVisible(0) on the meters). ────────────────────────────────────── @@ -279,11 +279,14 @@ public sealed class SelectedObjectController : IRetainedPanelController _overlay?.TrySetRetailState(state); } + private void OnSelectionTransition(SelectionTransition transition) + => ApplySelection(transition.SelectedObjectId); + public void Dispose() { if (_disposed) return; _disposed = true; - _unsubscribeSelectionChanged(OnSelectionChanged); + _selection.Changed -= OnSelectionTransition; _unsubscribeHealthChanged(OnHealthChanged); } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 520df898..7c859348 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -6,6 +6,7 @@ using AcDream.App.UI.Testing; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; +using AcDream.Core.Selection; using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Settings; @@ -31,7 +32,7 @@ public sealed record ChatRuntimeBindings(ChatVM ViewModel, Func Com public sealed record RadarRuntimeBindings( Func Snapshot, - Action SelectObject, + SelectionState Selection, Action SetUiLocked); public sealed record ToolbarRuntimeBindings( @@ -43,8 +44,7 @@ public sealed record ToolbarRuntimeBindings( ItemInteractionController ItemInteraction, Action? SendAddShortcut, Action? SendRemoveShortcut, - Action> SubscribeSelectionChanged, - Action> UnsubscribeSelectionChanged, + SelectionState Selection, Action> SubscribeHealthChanged, Action> UnsubscribeHealthChanged, Func IsHealthTarget, @@ -65,7 +65,8 @@ public sealed record InventoryRuntimeBindings( Action? SendNoLongerViewing, Action? SendPutItemInContainer, Action? SendWield, - ItemInteractionController ItemInteraction); + ItemInteractionController ItemInteraction, + SelectionState Selection); public sealed record RetailUiPersistenceBindings( SettingsStore Store, @@ -256,7 +257,7 @@ public sealed class RetailUiRuntime : IDisposable RadarController controller = RadarController.Bind( layout, _bindings.Radar.Snapshot, - _bindings.Radar.SelectObject, + guid => _bindings.Radar.Selection.Select(guid, SelectionChangeSource.Radar), setUiLocked: _bindings.Radar.SetUiLocked, datFont: _bindings.Assets.DefaultFont); RetailWindowFrame.Mount(Host.Root, radarRoot, _bindings.Assets.ResolveSprite, @@ -354,8 +355,7 @@ public sealed class RetailUiRuntime : IDisposable peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut); SelectedObjectController = Layout.SelectedObjectController.Bind( layout, - b.SubscribeSelectionChanged, - b.UnsubscribeSelectionChanged, + b.Selection, b.SubscribeHealthChanged, b.UnsubscribeHealthChanged, b.IsHealthTarget, @@ -570,13 +570,13 @@ public sealed class RetailUiRuntime : IDisposable InventoryRuntimeBindings b = _bindings.Inventory; InventoryController inventory = InventoryController.Bind( - layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, + layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection, _bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName, contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing, b.SendPutItemInContainer, b.ItemInteraction, () => CloseWindow(WindowNames.Inventory)); PaperdollController paperdoll = PaperdollController.Bind( - layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.SendWield, + layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield, contents, _bindings.Assets.DefaultFont, b.ItemInteraction); Host.WindowManager.AttachController( WindowNames.Inventory, diff --git a/src/AcDream.App/UI/TargetIndicatorPanel.cs b/src/AcDream.App/UI/TargetIndicatorPanel.cs index 364bbc18..441b3caf 100644 --- a/src/AcDream.App/UI/TargetIndicatorPanel.cs +++ b/src/AcDream.App/UI/TargetIndicatorPanel.cs @@ -24,7 +24,7 @@ namespace AcDream.App.UI; /// on internal state types: /// /// -/// selectedGuidProvider — host's current _selectedGuid. +/// selectedGuidProvider — Core SelectionState's current guid. /// entityResolver — returns /// for a given guid, or null if /// the entity is no longer in the world (despawned). diff --git a/src/AcDream.Core/Selection/SelectionState.cs b/src/AcDream.Core/Selection/SelectionState.cs new file mode 100644 index 00000000..de3d51be --- /dev/null +++ b/src/AcDream.Core/Selection/SelectionState.cs @@ -0,0 +1,106 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Selection; + +public enum SelectionChangeSource +{ + System, + World, + Radar, + Inventory, + Paperdoll, + Toolbar, + Keyboard, + Plugin, +} + +public enum SelectionChangeReason +{ + Selected, + Cleared, + SelectedObjectRemoved, + PreviousSelection, +} + +public readonly record struct SelectionTransition( + uint? PreviousObjectId, + uint? SelectedObjectId, + SelectionChangeSource Source, + SelectionChangeReason Reason); + +/// +/// Single owner for the client's selected object. This ports the state behavior +/// of retail ACCWeenieObject::SetSelectedObject @ 0x0058C2E0: changes are +/// deduplicated, the old selection becomes the previous selection, and plugin/UI +/// listeners receive one transition after state is committed. +/// +public sealed class SelectionState : ISelectionService +{ + private event Action? PluginChanged; + + public uint? SelectedObjectId { get; private set; } + public uint? PreviousObjectId { get; private set; } + public uint? PreviousValidObjectId { get; private set; } + + public event Action? Changed; + + event Action ISelectionService.Changed + { + add => PluginChanged += value; + remove => PluginChanged -= value; + } + + public bool Select( + uint objectId, + SelectionChangeSource source, + SelectionChangeReason reason = SelectionChangeReason.Selected) + => objectId == 0 + ? Clear(source) + : Set(objectId, source, reason); + + public bool Clear( + SelectionChangeSource source, + SelectionChangeReason reason = SelectionChangeReason.Cleared) + => Set(null, source, reason); + + public bool SelectPrevious(SelectionChangeSource source = SelectionChangeSource.Keyboard) + => PreviousObjectId is uint previous + && Set(previous, source, SelectionChangeReason.PreviousSelection); + + bool ISelectionService.Select(uint objectId) + => Select(objectId, SelectionChangeSource.Plugin); + + bool ISelectionService.Clear() + => Clear(SelectionChangeSource.Plugin); + + private bool Set( + uint? selectedObjectId, + SelectionChangeSource source, + SelectionChangeReason reason) + { + if (SelectedObjectId == selectedObjectId) + return false; + + uint? old = SelectedObjectId; + SelectedObjectId = selectedObjectId; + PreviousObjectId = old; + if (old is not null) + PreviousValidObjectId = old; + + var transition = new SelectionTransition(old, selectedObjectId, source, reason); + Changed?.Invoke(transition); + DispatchPluginChanged(new SelectionChangedEvent(old, selectedObjectId)); + return true; + } + + private void DispatchPluginChanged(SelectionChangedEvent change) + { + Action? listeners = PluginChanged; + if (listeners is null) return; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(change); } + catch { /* plugin exceptions never escape into client state changes */ } + } + } +} diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index dca64d7b..f3690107 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -10,5 +10,6 @@ public interface IPluginHost IPluginLogger Log { get; } IGameState State { get; } IEvents Events { get; } + ISelectionService Selection { get; } IUiRegistry Ui { get; } } diff --git a/src/AcDream.Plugin.Abstractions/ISelectionService.cs b/src/AcDream.Plugin.Abstractions/ISelectionService.cs new file mode 100644 index 00000000..0957599a --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/ISelectionService.cs @@ -0,0 +1,19 @@ +namespace AcDream.Plugin.Abstractions; + +/// A plugin-safe selection transition matching retail's selected-target callback. +public readonly record struct SelectionChangedEvent( + uint? PreviousObjectId, + uint? SelectedObjectId); + +/// +/// Shared selected-object service. Plugins read and change the same selection +/// used by the world, radar, inventory, paperdoll, and toolbar. +/// +public interface ISelectionService +{ + uint? SelectedObjectId { get; } + uint? PreviousObjectId { get; } + event Action Changed; + bool Select(uint objectId); + bool Clear(); +} diff --git a/tests/AcDream.App.Tests/UI/InteractionStateTests.cs b/tests/AcDream.App.Tests/UI/InteractionStateTests.cs new file mode 100644 index 00000000..c60f1e75 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/InteractionStateTests.cs @@ -0,0 +1,32 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public sealed class InteractionStateTests +{ + [Fact] + public void Modes_HaveOneOwnerAndOneDeduplicatedTransitionStream() + { + var state = new InteractionState(); + var changes = new List(); + state.Changed += changes.Add; + + Assert.True(state.EnterUse()); + Assert.False(state.EnterUse()); + Assert.True(state.EnterExamine()); + Assert.True(state.EnterUseItemOnTarget(0x100u)); + Assert.True(state.Clear()); + + Assert.Equal(4, changes.Count); + Assert.Equal(InteractionModeKind.None, state.Current.Kind); + Assert.Equal(0x100u, changes[2].Current.SourceObjectId); + } + + [Fact] + public void UseItemOnTarget_RejectsMissingSource() + { + var state = new InteractionState(); + Assert.Throws(() => state.EnterUseItemOnTarget(0)); + Assert.Equal(InteractionMode.None, state.Current); + } +} diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 0a54512f..7b50c1e0 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -77,6 +77,10 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Controller.IsTargetModeActive); Assert.True(h.Controller.IsPendingSource(0x50000A01u)); + Assert.Equal(InteractionModeKind.UseItemOnTarget, + h.Controller.InteractionState.Current.Kind); + Assert.Equal(0x50000A01u, + h.Controller.InteractionState.Current.SourceObjectId); Assert.Empty(h.UseWithTarget); } diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 18806123..5daa54f2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; +using AcDream.Core.Selection; using Xunit; namespace AcDream.App.Tests.UI.Layout; @@ -56,7 +57,8 @@ public class InventoryControllerTests int? strength = 100, List? uses = null, List? closes = null, List<(uint item, uint container, int placement)>? puts = null, string? ownerName = null, - Action? onClose = null) + Action? onClose = null, + SelectionState? selection = null) => InventoryController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0u, strength: () => strength, datFont: null, @@ -64,7 +66,8 @@ public class InventoryControllerTests sendUse: uses is null ? null : g => uses.Add(g), sendNoLongerViewing: closes is null ? null : g => closes.Add(g), sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)), - onClose: onClose); + onClose: onClose, + selection: selection ?? new SelectionState()); private static UiButton MakeButton(uint id) { @@ -316,7 +319,8 @@ public class InventoryControllerTests { var (layout, grid, containers, top, _, _, _, _) = BuildLayout(); InventoryController.Bind(layout, new ClientObjectTable(), () => Player, - iconIds: (_, _, _, _, _) => 0u, strength: () => 100, datFont: null, + iconIds: (_, _, _, _, _) => 0u, strength: () => 100, + selection: new SelectionState(), datFont: null, contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu); Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell @@ -414,6 +418,27 @@ public class InventoryControllerTests Assert.Empty(closes); } + [Fact] + public void ClickGridItem_updatesSharedSelection_andExternalSelectionMovesSquare() + { + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xAu, Player, slot: 0); + SeedContained(objects, 0xBu, Player, slot: 1); + var selection = new SelectionState(); + Bind(layout, objects, selection: selection); + + grid.GetItem(0)!.Clicked!(); + + Assert.Equal(0xAu, selection.SelectedObjectId); + Assert.True(grid.GetItem(0)!.Selected); + + selection.Select(0xBu, SelectionChangeSource.World); + + Assert.False(grid.GetItem(0)!.Selected); + Assert.True(grid.GetItem(1)!.Selected); + } + [Fact] public void TargetMode_suppressesSelectedSquare_onPendingSource() { @@ -439,6 +464,7 @@ public class InventoryControllerTests InventoryController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0u, strength: () => 100, + selection: new SelectionState(), datFont: null, itemInteraction: interaction); grid.GetItem(0)!.Clicked!(); @@ -469,7 +495,7 @@ public class InventoryControllerTests (ItemType type, uint icon)? mainPackCall = null; InventoryController.Bind(layout, objects, () => Player, iconIds: (t, icon, _, _, _) => { if (icon == 0x0600127Eu) mainPackCall = (t, icon); return 0u; }, - strength: () => 100, datFont: null); + strength: () => 100, selection: new SelectionState(), datFont: null); // Retail draws a constant backpack over the Container type-underlay (IconData::RenderIcons // IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22). diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs index 71d7c6e9..32e074fe 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs @@ -110,6 +110,7 @@ public class InventoryFrameImportProbe playerGuid: static () => 0u, iconIds: static (_, _, _, _, _) => 0u, strength: static () => 100, + selection: new AcDream.Core.Selection.SelectionState(), datFont: null, onClose: () => closes++); diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index 5e6ee14c..d14c1eb1 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; +using AcDream.Core.Selection; using Xunit; namespace AcDream.App.Tests.UI.Layout; @@ -34,11 +35,13 @@ public class PaperdollControllerTests } private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects, - List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u) + List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u, + SelectionState? selection = null) => PaperdollController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0x1234u, sendWield: wields is null ? null : (i, m) => wields.Add((i, m)), - emptySlotSprite: emptySlot); + emptySlotSprite: emptySlot, + selection: selection ?? new SelectionState()); private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc) { @@ -63,6 +66,21 @@ public class PaperdollControllerTests Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty } + [Fact] + public void ClickEquippedItem_updatesSharedSelection() + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedEquipped(objects, 0xA01u, EquipMask.HeadWear); + var selection = new SelectionState(); + Bind(layout, objects, selection: selection); + + lists[HeadSlot].Cell.Clicked!(); + + Assert.Equal(0xA01u, selection.SelectedObjectId); + Assert.True(lists[HeadSlot].Cell.Selected); + } + [Fact] public void Populate_matches_a_weapon_into_the_composite_slot() { diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs index d5bc649f..80fdf7f7 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; +using AcDream.Core.Selection; using Xunit; namespace AcDream.App.Tests.UI.Layout; @@ -65,7 +66,7 @@ public class SelectedObjectControllerTests private sealed class Harness { - public Action? SelectionHandler; + public readonly SelectionState Selection = new(); public Action? HealthHandler; public readonly List QueryHealthCalls = new(); @@ -75,17 +76,19 @@ public class SelectedObjectControllerTests public readonly Dictionary HasHealthMap = new(); public readonly Dictionary StackMap = new(); - public void FireSelection(uint? g) => SelectionHandler?.Invoke(g); + public void FireSelection(uint? g) + { + if (g is { } guid) + Selection.Select(guid, SelectionChangeSource.System); + else + Selection.Clear(SelectionChangeSource.System); + } public void FireHealth(uint g, float pct) => HealthHandler?.Invoke(g, pct); public SelectedObjectController Bind(ImportedLayout layout, UiDatFont? datFont = null) => SelectedObjectController.Bind( layout, - subscribeSelectionChanged: h => SelectionHandler = h, - unsubscribeSelectionChanged: h => - { - if (SelectionHandler == h) SelectionHandler = null; - }, + selection: Selection, subscribeHealthChanged: h => HealthHandler = h, unsubscribeHealthChanged: h => { @@ -362,7 +365,6 @@ public class SelectedObjectControllerTests h.NameMap[0x12345678u] = "Something"; var c = h.Bind(layout); - Assert.NotNull(h.SelectionHandler); Assert.Null(Record.Exception(() => h.FireSelection(0x12345678u))); Assert.Null(Record.Exception(() => h.FireHealth(0x12345678u, 0.5f))); Assert.Null(Record.Exception(() => c.Tick(0.5))); @@ -425,7 +427,6 @@ public class SelectedObjectControllerTests h.FireSelection(0xAA09u); h.FireHealth(0xAA09u, 0.5f); - Assert.Null(h.SelectionHandler); Assert.Null(h.HealthHandler); Assert.False(healthMeterEl.Visible); Assert.Empty(h.QueryHealthCalls); diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs index 9d2e8939..07d5a34a 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs @@ -32,6 +32,7 @@ public sealed class RetailUiInteractionFlowTests public readonly List Uses = new(); public readonly List<(uint Source, uint Target)> UseWithTarget = new(); public readonly List<(uint Item, uint Mask)> Wields = new(); + public readonly AcDream.Core.Selection.SelectionState Selection = new(); public readonly List Drops = new(); public long Now = 10_000; @@ -149,6 +150,7 @@ public sealed class RetailUiInteractionFlowTests playerGuid: () => Player, iconIds: static (_, _, _, _, _) => 0x1234u, strength: () => 100, + selection: Selection, datFont: null, itemInteraction: interaction); @@ -167,6 +169,7 @@ public sealed class RetailUiInteractionFlowTests Objects, playerGuid: () => Player, iconIds: static (_, _, _, _, _) => 0x1234u, + selection: Selection, sendWield: (item, mask) => Wields.Add((item, mask)), emptySlotSprite: 0x06004D20u); diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index da508aab..a55fb398 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -1,4 +1,5 @@ using AcDream.Core.Plugins; +using AcDream.Core.Selection; using AcDream.Plugin.Abstractions; namespace AcDream.Core.Tests.Plugins; @@ -30,6 +31,7 @@ public class PluginLoaderTests public IPluginLogger Log { get; } = new StubLogger(); public IGameState State { get; } = new StubState(); public IEvents Events { get; } = new StubEvents(); + public ISelectionService Selection { get; } = new SelectionState(); public IUiRegistry Ui { get; } = new StubUiRegistry(); } diff --git a/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs b/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs new file mode 100644 index 00000000..ae4f0c4b --- /dev/null +++ b/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs @@ -0,0 +1,100 @@ +using AcDream.Core.Selection; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Tests.Selection; + +public sealed class SelectionStateTests +{ + [Fact] + public void Select_CommitsOneTransitionAndTracksPreviousLikeRetail() + { + var state = new SelectionState(); + var transitions = new List(); + state.Changed += transitions.Add; + + Assert.True(state.Select(0x50000001u, SelectionChangeSource.World)); + Assert.True(state.Select(0x50000002u, SelectionChangeSource.Radar)); + + Assert.Equal(0x50000002u, state.SelectedObjectId); + Assert.Equal(0x50000001u, state.PreviousObjectId); + Assert.Equal(0x50000001u, state.PreviousValidObjectId); + Assert.Equal(2, transitions.Count); + Assert.Equal(SelectionChangeSource.Radar, transitions[1].Source); + Assert.Equal(SelectionChangeReason.Selected, transitions[1].Reason); + } + + [Fact] + public void Select_SameObject_IsDeduplicated() + { + var state = new SelectionState(); + int changed = 0; + state.Changed += _ => changed++; + + Assert.True(state.Select(0x50000001u, SelectionChangeSource.World)); + Assert.False(state.Select(0x50000001u, SelectionChangeSource.Inventory)); + + Assert.Equal(1, changed); + Assert.Null(state.PreviousObjectId); + } + + [Fact] + public void SelectPrevious_SwapsCurrentAndPrevious() + { + var state = new SelectionState(); + state.Select(0x50000001u, SelectionChangeSource.World); + state.Select(0x50000002u, SelectionChangeSource.World); + + Assert.True(state.SelectPrevious()); + + Assert.Equal(0x50000001u, state.SelectedObjectId); + Assert.Equal(0x50000002u, state.PreviousObjectId); + } + + [Fact] + public void ClearRemoved_PreservesRemovedObjectAsPreviousValid() + { + var state = new SelectionState(); + SelectionTransition transition = default; + state.Changed += value => transition = value; + state.Select(0x50000001u, SelectionChangeSource.World); + + Assert.True(state.Clear( + SelectionChangeSource.System, + SelectionChangeReason.SelectedObjectRemoved)); + + Assert.Null(state.SelectedObjectId); + Assert.Equal(0x50000001u, state.PreviousObjectId); + Assert.Equal(0x50000001u, state.PreviousValidObjectId); + Assert.Equal(SelectionChangeReason.SelectedObjectRemoved, transition.Reason); + } + + [Fact] + public void PluginService_UsesSameStateAndEventStream() + { + var state = new SelectionState(); + ISelectionService plugin = state; + SelectionChangedEvent change = default; + plugin.Changed += value => change = value; + + Assert.True(plugin.Select(0x50000001u)); + + Assert.Equal(0x50000001u, state.SelectedObjectId); + Assert.Equal(0x50000001u, plugin.SelectedObjectId); + Assert.Null(change.PreviousObjectId); + Assert.Equal(0x50000001u, change.SelectedObjectId); + } + + [Fact] + public void PluginListenerFailure_DoesNotEscapeOrBlockOtherPlugins() + { + var state = new SelectionState(); + ISelectionService plugin = state; + int delivered = 0; + plugin.Changed += _ => throw new InvalidOperationException("plugin failure"); + plugin.Changed += _ => delivered++; + + Assert.Null(Record.Exception(() => plugin.Select(0x50000001u))); + Assert.Equal(1, delivered); + Assert.Equal(0x50000001u, state.SelectedObjectId); + } +}