From 60387668d05a25fc710d81b38a2ce45fff008562 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 23 Jul 2026 08:07:47 +0200 Subject: [PATCH] feat(ui): drive toolbar use state from selection Port the retail selected-object availability predicate into Core and project it through the shared interaction owner. The imported hand now follows canonical selection/object notices, keeps weapon and targeted-tool activation on the existing wield/use cursor paths, and ghosts empty or explicitly unusable selections per the connected UX requirement. Co-authored-by: Codex --- .../retail-divergence-register.md | 3 +- docs/plans/2026-04-11-roadmap.md | 16 +-- docs/plans/2026-05-12-milestones.md | 5 + ...2026-07-23-world-interaction-completion.md | 45 ++++++- ...0-retail-toolbar-interaction-pseudocode.md | 11 +- .../UI/ItemInteractionController.cs | 17 +++ .../UI/Layout/ToolbarController.cs | 31 ++++- src/AcDream.App/UI/RetailUiRuntime.cs | 1 + .../Items/ItemInteractionPolicy.cs | 16 +++ .../UI/Layout/ToolbarControllerTests.cs | 118 ++++++++++++++++++ .../Items/ItemInteractionPolicyTests.cs | 22 ++++ 11 files changed, 269 insertions(+), 16 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 66b0a092..024b5dfa 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -107,7 +107,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 88 active rows +## 3. Documented approximation (AP) — 89 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 @@ -218,6 +218,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | | AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | | AP-121 | The `/framerate` command persists acdream's live ShowFps value in the modern `settings.json` Display bag after performing retail's live toggle/notice behavior. The named retail `DoFrameRate` body itself only flips `fShowFramerate` and sends `SetFramerateDisplay`; no equivalent persistence write occurs in that command body. | `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ToggleFrameRate`) | This preserves acdream's shipped L.0 cross-launch display preference while keeping the live retail mechanism and SmartBox visibility result exact; it changes no gameplay or wire state | A relaunch can retain FPS visibility where the named retail command body alone would not; a future port of retail's wider preference owner must avoid double-persisting or applying conflicting startup state | `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`; `CM_UI::SendNotice_SetFramerateDisplay @ 0x0047A050`; `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` | +| AP-122 | The toolbar Use hand is Ghosted when selection is empty. Retail sets state 1 (Normal) and an empty-selection click enters generic `TARGET_MODE_USE`; selected-object enablement and activation remain retail-faithful. | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`RefreshUseButton`) | User explicitly requires no selection to present as unavailable. The disabled retained button consumes the click without opening a cursor; every nonempty selection still uses the exact retail CombatUse/type/ItemUses predicate and the shared activation owner. | Generic pick-then-use mode cannot be entered from the empty toolbar hand; an object must first be selected, after which the same Use command is available. | `gmToolbarUI::HandleSelectionChanged @ 0x004BF380`; `gmToolbarUI::ListenToElementMessage @ 0x004BEE90` | ## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index b0fd0a7c..089d390f 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -12,14 +12,14 @@ The active work order is It closes six remaining retail interaction surfaces before larger M4 subsystem bodies begin: spell-bar overflow controls, status-bar Use/Assess commands, assessment information, equipped-child picking, vendor browsing, and -server-authoritative vendor transactions. Slice 1 is active and ports the -DAT-authored arrow-only favorite-spell scrollbar through the shared retained -scrollbar/list model. The corrected import preserves authored arrow sides and -rollover/pressed media, passive refresh preserves manual offsets, and the -mixed-parent DAT anchor chain produces the fixed retail capacity of 18 visible -favorite cells (nine numbered plus nine unnumbered). Its -implementation and complete Release suite pass; connected overflow-arrow -visual acceptance is pending. +server-authoritative vendor transactions. Slice 1's DAT-authored arrow-only +favorite-spell scrollbar, fixed 18-cell capacity, authored arrow sides/media, +and persistent manual offset passed the connected gate. Slice 2 is active: the +status hand now follows canonical selection/object notices, ghosts empty and +unusable selections, and routes enabled weapons, direct-use items, and +use-on-target tools through the existing interaction transaction and cursor +owners. Its connected hand-state gate is pending; the magnifying-glass Assess +command follows in the same slice. --- diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 1225be99..b1bc8d83 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -89,6 +89,11 @@ transactions. This is deliberately using the extracted interaction owners and canonical shared main-panel host before quest/emote/character-creation bodies broaden the feature surface. +Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2 is +active: the status hand's selected-object availability and existing +Use/AutoWield/use-on-target command routing are code-complete, with the +connected state/cursor gate pending before the Assess half continues. + Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus registered TS-50/TS-51/TS-53 timing residuals (the complete-root-Frame/object-workset diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 2c771bf2..b4521b37 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -1,6 +1,7 @@ # World interaction completion — pre-M4 program -**Status:** Slice 1 code-complete 2026-07-23; connected visual gate pending. +**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand state and command +path code-complete; connected visual gate pending before Assess continues. **Milestone:** M4 prerequisite/preamble. **Architecture:** retained gameplay UI over shared selection, object, and interaction state. `GameWindow` remains a composition/callback shell. @@ -52,9 +53,21 @@ spells. Passive object/endowment refresh preserves a manual offset. The mixed producing exactly 18 visible 32-pixel favorite cells (nine numbered plus nine unnumbered) without consuming the overflow. The initial 57-test/full-suite gate passed; the corrective 104-test focus set, 3,472 App tests / 3 skips, Release solution -build, and 7,845 complete-solution tests / 5 skips pass. The connected gate is -pending after user feedback on arrow sides, rollover, width, and offset -persistence. +build, and 7,845 complete-solution tests / 5 skips pass. The corrected +18-cell bar and both overflow directions passed the connected gate. + +Slice 2 now drives the toolbar hand from canonical `SelectionState` changes and +live `ClientObjectTable` updates. The pure Core predicate ports +`gmToolbarUI::HandleSelectionChanged`: combat-use items, armor/clothing/jewelry, +and `ItemUses` values without `USEABLE_NO` remain active. Clicking still enters +the existing `ItemInteractionController` command path, so weapons use the +server-confirmed AutoWield transaction and targeted tools enter the existing +use-on-target cursor. Empty selection and explicitly unusable spell components +are ghosted and ignore clicks. Empty-selection ghosting is the user's explicit +choice over retail's generic `TARGET_MODE_USE` entry and is registered as +AP-122. The 14-test Core interaction focus, 49-test toolbar focus, Release +solution build, 3,474 App tests / 3 skips, and 7,848 complete-solution tests / +5 skips pass. ## Slice 1 — spell-bar overflow arrows @@ -115,3 +128,27 @@ Named retail references and executable pseudocode are recorded in - Existing stack, combat-power, chat, inventory, spellbook, and external container scrollbars retain their current behavior. - No substantial feature body enters `GameWindow`. + +## Slice 2 — status Use/Assess commands + +### Use-hand implementation + +1. `ItemInteractionPolicy.IsToolbarUseEnabled` is the pure named-retail + selection predicate. +2. `ItemInteractionController.IsToolbarUseEnabled` adapts the selected live + object without triggering a request or consuming the use throttle. +3. `ToolbarController` subscribes to canonical `SelectionState.Changed` and + selected-object add/update/remove notices, then sets the imported button's + normal or ghosted state through `UiButton.Enabled`. +4. Enabled clicks retain the one existing activation path: + equipment enters `AutoWieldController`, ordinary use enters the normal Use + request owner, and targeted items enter `UseItemOnTarget`, which already + owns the retail target cursor. + +### Remaining Slice 2 work + +- Port the magnifying-glass Assess command state and activation. +- Run the connected Use-hand visual gate: no selection, spell component, + weapon, and health kit. +- Continue directly into the Assess information slice after the combined + status-control gate. 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 2b51e171..da891446 100644 --- a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md +++ b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md @@ -579,8 +579,15 @@ HandleSelectionChanged(): // 0x004BF380 ``` The exact item-type mask in the use-button ghost test is visible as separate -`InqType() & 4`, `& 2`, and `& 8` checks. Preserve those numeric tests until the -retail `ITEM_TYPE` names are pinned. +`InqType() & 4`, `& 2`, and `& 8` checks. The verbatim `ITEM_TYPE` enum now pins +those values as Clothing, Armor, and Jewelry respectively. + +**2026-07-23 connected requirement:** acdream intentionally ghosts the hand +when the selection is empty. Retail instead sets state 1 and lets a click enter +generic `TARGET_MODE_USE` (see `ListenToElementMessage @ 0x004BEE90`). Selected +objects still use the exact predicate above and still activate through the +ordinary `ItemHolder::UseObject` path. The empty-selection difference is +registered as AP-122. ### 5.3 Meter notices diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index d2c872ac..6fd62ea5 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -496,6 +496,23 @@ public sealed class ItemInteractionController : IDisposable return true; } + /// + /// Projects retail gmToolbarUI::HandleSelectionChanged @ 0x004BF380 + /// from the live selected object's public description. This is deliberately + /// a read-only query; activation still flows through . + /// + public bool IsToolbarUseEnabled(uint selectedObjectId) + { + if (selectedObjectId == 0 + || _objects.Get(selectedObjectId) is not { } item) + return false; + + return ItemInteractionPolicy.IsToolbarUseEnabled( + item.Type, + item.CombatUse ?? 0, + item.Useability ?? ItemUseability.Undef); + } + public bool ActivateItem(uint itemGuid) { if (itemGuid == 0) return false; diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 84a9a4f2..5e1fd48a 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; +using AcDream.Core.Selection; namespace AcDream.App.UI.Layout; @@ -73,6 +74,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont private readonly ItemInteractionController? _itemInteraction; private readonly Action? _selectItem; private readonly Func _selectedObjectId; + private readonly SelectionState? _selection; private readonly Func? _playerGuid; private readonly Action? _sendPutItemInContainer; private uint _ammoObjectId; @@ -106,6 +108,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont Action? toggleCombat = null, Action? selectItem = null, Func? selectedObjectId = null, + SelectionState? selection = null, Func? playerGuid = null, Action? sendPutItemInContainer = null, UiDatFont? ammoFont = null, @@ -125,6 +128,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont _sendRemoveShortcut = sendRemoveShortcut; _selectItem = selectItem; _selectedObjectId = selectedObjectId ?? (() => 0u); + _selection = selection; _playerGuid = playerGuid; _sendPutItemInContainer = sendPutItemInContainer; @@ -196,6 +200,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont // Wire live combat-mode changes if a CombatState was provided. if (_combatState is not null) _combatState.CombatModeChanged += SetCombatMode; + if (_selection is not null) + _selection.Changed += OnSelectionChanged; // D.5.4: the table now holds ALL objects (creatures, NPCs, etc.), so filter // to our 18 shortcut guids — else every creature spawn in a busy zone @@ -206,6 +212,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont repo.ObjectMoved += OnRepositoryObjectMoved; repo.Cleared += OnRepositoryCleared; RefreshAmmo(); + RefreshUseButton(); } private void OnRepositoryObjectChanged(ClientObject obj) @@ -214,6 +221,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont RefreshAmmo(); if (IsShortcutGuid(obj.ObjectId)) Populate(); + if (obj.ObjectId == _selectedObjectId()) + RefreshUseButton(); } private void OnRepositoryObjectMoved(ClientObjectMove move) @@ -234,6 +243,23 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont { RefreshAmmo(); Populate(); + RefreshUseButton(); + } + + private void OnSelectionChanged(SelectionTransition _) + => RefreshUseButton(); + + private void RefreshUseButton() + { + if (_useButton is null) + return; + + // Retail normally leaves state 1 active when selectedID == 0 so the + // click can enter generic TARGET_MODE_USE. acdream intentionally + // ghosts that empty state per the connected UX requirement; every + // selected-object classification below is the exact retail predicate. + _useButton.Enabled = + _itemInteraction?.IsToolbarUseEnabled(_selectedObjectId()) == true; } private bool IsAmmoRelated(ClientObject obj) @@ -330,6 +356,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont Action? toggleCombat = null, Action? selectItem = null, Func? selectedObjectId = null, + SelectionState? selection = null, Func? playerGuid = null, Action? sendPutItemInContainer = null, UiDatFont? ammoFont = null, @@ -338,7 +365,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState, regularDigits, ghostedDigits, emptyDigits, itemInteraction, sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem, - selectedObjectId, playerGuid, sendPutItemInContainer, ammoFont, + selectedObjectId, selection, playerGuid, sendPutItemInContainer, ammoFont, dragIconIds); c.Populate(); return c; @@ -736,6 +763,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont if (_combatState is not null) _combatState.CombatModeChanged -= SetCombatMode; + if (_selection is not null) + _selection.Changed -= OnSelectionChanged; _repo.ObjectAdded -= OnRepositoryObjectChanged; _repo.ObjectUpdated -= OnRepositoryObjectChanged; _repo.ObjectRemoved -= OnRepositoryObjectChanged; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index f57249bb..390c5236 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -689,6 +689,7 @@ public sealed class RetailUiRuntime : IDisposable toggleCombat: b.ToggleCombat, selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar), selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u, + selection: b.Selection, playerGuid: b.PlayerGuid, sendPutItemInContainer: b.SendPutItemInContainer, ammoFont: _bindings.Assets.DefaultFont, diff --git a/src/AcDream.Core/Items/ItemInteractionPolicy.cs b/src/AcDream.Core/Items/ItemInteractionPolicy.cs index 0a52b51d..a4fdc9ee 100644 --- a/src/AcDream.Core/Items/ItemInteractionPolicy.cs +++ b/src/AcDream.Core/Items/ItemInteractionPolicy.cs @@ -151,6 +151,22 @@ public static class ItemInteractionPolicy private static readonly EquipMask BodyLocations = (EquipMask)0x00007E00u; private static readonly EquipMask ClothingLocations = (EquipMask)0x080001FFu; private static readonly EquipMask HeldLocations = (EquipMask)0x7C0F8000u; + private const ItemType ToolbarEquipmentTypes = + ItemType.Armor | ItemType.Clothing | ItemType.Jewelry; + + /// + /// Exact selected-object predicate used by retail's toolbar hand. + /// gmToolbarUI::HandleSelectionChanged @ 0x004BF380 leaves the + /// button active for combat-use objects, armor/clothing/jewelry, or an + /// ItemUses value whose USEABLE_NO bit is clear. + /// + public static bool IsToolbarUseEnabled( + ItemType type, + byte combatUse, + uint useability) + => combatUse != 0 + || (type & ToolbarEquipmentTypes) != 0 + || (useability & ItemUseability.No) == 0; public static ItemPrimaryUseResult DetermineUseResult( in ItemPolicyObject item, diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index c21d6e1a..c9e0095d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -5,6 +5,7 @@ using AcDream.App.UI.Layout; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; +using AcDream.Core.Selection; using Xunit; namespace AcDream.App.Tests.UI.Layout; @@ -515,6 +516,123 @@ public class ToolbarControllerTests Assert.Equal(InteractionModeKind.Examine, interaction.InteractionState.Current.Kind); } + [Fact] + public void UseButton_tracksEmptyUnusableAndTargetedSelection() + { + const uint player = 0x50000001u; + const uint pack = 0x50000002u; + const uint component = 0x50000003u; + const uint healthKit = 0x50000004u; + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature }); + repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container }); + repo.MoveItem(pack, player, 0); + repo.AddOrUpdate(new ClientObject + { + ObjectId = component, + Type = ItemType.SpellComponents, + Useability = ItemUseability.No, + }); + repo.MoveItem(component, pack, 0); + repo.AddOrUpdate(new ClientObject + { + ObjectId = healthKit, + Type = ItemType.Misc, + Useability = 0x00220008u, + TargetType = (uint)ItemType.Creature, + }); + repo.MoveItem(healthKit, pack, 1); + + var selection = new SelectionState(); + using var interaction = new ItemInteractionController( + repo, + () => player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null); + using var controller = ToolbarController.Bind( + layout, + repo, + () => Array.Empty(), + iconIds: (_, _, _, _, _) => 0u, + useItem: _ => { }, + itemInteraction: interaction, + selectedObjectId: () => selection.SelectedObjectId ?? 0u, + selection: selection); + var use = Assert.IsType(layout.FindElement(UseButtonId)); + + Assert.False(use.Enabled); + Assert.Equal("Ghosted", use.ActiveState); + use.OnEvent(new UiEvent(0u, use, UiEventType.Click)); + Assert.Equal(InteractionModeKind.None, interaction.InteractionState.Current.Kind); + + selection.Select(component, SelectionChangeSource.Inventory); + Assert.False(use.Enabled); + Assert.Equal("Ghosted", use.ActiveState); + + selection.Select(healthKit, SelectionChangeSource.Inventory); + Assert.True(use.Enabled); + Assert.Equal("Normal", use.ActiveState); + use.OnEvent(new UiEvent(0u, use, UiEventType.Click)); + Assert.Equal( + InteractionModeKind.UseItemOnTarget, + interaction.InteractionState.Current.Kind); + + selection.Clear(SelectionChangeSource.Inventory); + Assert.False(use.Enabled); + Assert.Equal("Ghosted", use.ActiveState); + } + + [Fact] + public void UseButton_selectedCombatItem_routesExistingWieldPath() + { + const uint player = 0x50000001u; + const uint pack = 0x50000002u; + const uint sword = 0x50000003u; + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature }); + repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container }); + repo.MoveItem(pack, player, 0); + repo.AddOrUpdate(new ClientObject + { + ObjectId = sword, + Type = ItemType.MeleeWeapon, + CombatUse = 1, + Useability = ItemUseability.No, + ValidLocations = EquipMask.MeleeWeapon, + }); + repo.MoveItem(sword, pack, 0); + + var selection = new SelectionState(); + selection.Select(sword, SelectionChangeSource.Inventory); + var wields = new List<(uint Item, uint Location)>(); + using var interaction = new ItemInteractionController( + repo, + () => player, + sendUse: null, + sendUseWithTarget: null, + sendWield: (item, location) => wields.Add((item, location)), + sendDrop: null); + using var controller = ToolbarController.Bind( + layout, + repo, + () => Array.Empty(), + iconIds: (_, _, _, _, _) => 0u, + useItem: _ => { }, + itemInteraction: interaction, + selectedObjectId: () => selection.SelectedObjectId ?? 0u, + selection: selection); + var use = Assert.IsType(layout.FindElement(UseButtonId)); + + Assert.True(use.Enabled); + use.OnEvent(new UiEvent(0u, use, UiEventType.Click)); + + Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, wields); + } + // ── C1: combat-mode indicator tests ───────────────────────────────────── [Fact] diff --git a/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs b/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs index e1f70d91..e7d478db 100644 --- a/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs +++ b/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs @@ -7,6 +7,28 @@ public sealed class ItemInteractionPolicyTests private const uint Player = 0x50000001u; private const uint GroundContainer = 0x70000001u; + [Fact] + public void ToolbarUseEnabled_matchesRetailSelectionPredicate() + { + Assert.True(ItemInteractionPolicy.IsToolbarUseEnabled( + ItemType.MeleeWeapon, + combatUse: 1, + useability: ItemUseability.No)); + Assert.True(ItemInteractionPolicy.IsToolbarUseEnabled( + ItemType.Armor | ItemType.Clothing, + combatUse: 0, + useability: ItemUseability.No)); + Assert.True(ItemInteractionPolicy.IsToolbarUseEnabled( + ItemType.Misc, + combatUse: 0, + useability: 0x00220008u)); + + Assert.False(ItemInteractionPolicy.IsToolbarUseEnabled( + ItemType.SpellComponents, + combatUse: 0, + useability: ItemUseability.No)); + } + [Fact] public void DetermineUseResult_looseItemAndComponentPack_matchRetailPickupBranch() {