From 3e31b0ac708851f374879a99a8b2281870bbe53c Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 06:47:54 +0200 Subject: [PATCH] fix(ui): select and examine favorite spells like retail --- AGENTS.md | 7 +- CLAUDE.md | 7 +- docs/ISSUES.md | 11 + .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 11 +- docs/plans/2026-05-12-milestones.md | 6 + ...2026-07-23-world-interaction-completion.md | 18 +- ...-retail-magic-ui-and-casting-pseudocode.md | 79 +++++ .../InteractionRetainedUiComposition.cs | 35 +++ src/AcDream.App/Spells/MagicRuntime.cs | 52 +++- .../SpellComponentRequirementService.cs | 25 ++ .../UI/ItemInteractionController.cs | 25 ++ .../UI/Layout/AppraisalUiController.cs | 270 +++++++++++++++++- .../SpellExamineComponentTemplateFactory.cs | 95 ++++++ .../UI/Layout/SpellcastingUiController.cs | 27 +- src/AcDream.App/UI/RetailUiRuntime.cs | 19 +- src/AcDream.App/UI/UiCatalogSlot.cs | 8 + src/AcDream.App/UI/UiItemList.cs | 19 ++ src/AcDream.App/UI/UiItemSlot.cs | 2 +- .../SpellComponentRequirementServiceTests.cs | 33 +++ .../UI/DragDropSpineTests.cs | 50 ++++ .../UI/Layout/AppraisalUiControllerTests.cs | 159 ++++++++++- .../Layout/SpellcastingUiControllerTests.cs | 48 +++- 23 files changed, 974 insertions(+), 34 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/SpellExamineComponentTemplateFactory.cs diff --git a/AGENTS.md b/AGENTS.md index 1947ec2e..e12d6678 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,8 +115,8 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; the appraisal and retained-item input corrections pass 3,548 -App tests / 3 skips and 7,979 complete-solution tests / 5 skips. +Release builds; the appraisal and retained catalog-input corrections pass +3,553 App tests / 3 skips and 7,984 complete-solution tests / 5 skips. **Current work order:** the six-slice pre-M4 world-interaction completion program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite @@ -126,7 +126,8 @@ transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are user-accepted. Slice 3's first connected gate exposed and corrected the monster-response flag mismatch, non-retail shared-panel mounting, and missing inscription transaction. The creature and common-item report corrections are -user-accepted; press-time retained-item selection is the current visual gate. +user-accepted; favorite-spell press-time selection and right-click local +examination are the current visual gate. **Structural prerequisite before new M4 subsystem work:** all eight behavior-preserving `GameWindow` decomposition slices and the automated diff --git a/CLAUDE.md b/CLAUDE.md index 9da6c181..79e8a0cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,8 +113,8 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; the appraisal and retained-item input corrections pass 3,548 -App tests / 3 skips and 7,979 complete-solution tests / 5 skips. +Release builds; the appraisal and retained catalog-input corrections pass +3,553 App tests / 3 skips and 7,984 complete-solution tests / 5 skips. **Current work order:** the six-slice pre-M4 world-interaction completion program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite @@ -124,7 +124,8 @@ transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are user-accepted. Slice 3's first connected gate exposed and corrected the monster-response flag mismatch, non-retail shared-panel mounting, and missing inscription transaction. The creature and common-item report corrections are -user-accepted; press-time retained-item selection is the current visual gate. +user-accepted; favorite-spell press-time selection and right-click local +examination are the current visual gate. **Structural prerequisite before new M4 subsystem work:** all eight behavior-preserving `GameWindow` decomposition slices and the automated diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6ce81815..c52d5625 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -158,6 +158,14 @@ three-pixel drag threshold. Target mode is intercepted at that same press. Bag opening, item use/equip, and shortcut activation remain completed click/double-click actions, so a drag or target-consumed press cannot also activate the item. +The corresponding favorite-spell branch is now ported without conflating a +spell ID with a physical object GUID. Favorite selection occurs on left press; +right-click opens the authored local Spell examination subview with +name, school, mana, duration, range, description, and the current appropriate +component formula. It sends no Appraise request and takes no busy reference, +so the status-bar magnifier remains object-only. Switching the shared floaty +from a pending object appraisal to a spell releases exactly that appraisal +transaction before showing the SpellPanel. **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`; @@ -185,6 +193,9 @@ Right-clicking an occupied retained item cell selects and examines it without using, equipping, looting, or dragging it. Left-pressing one selects it before the button is released; holding and dragging preserves that selection without activating the item. +Left-pressing a favorite spell likewise changes its selection immediately. +Right-clicking it opens the spell examination subview; selecting it and using +the status-bar magnifier does not examine the spell. The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`, then its description after one retail paragraph break, without the bogus 255-item/255-container line. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 728a96dd..cfb43e4a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -205,7 +205,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | -| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | +| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | | AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 3da76840..5b793787 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-07-23. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-24. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -38,7 +38,14 @@ selects and appraises occupied inventory, external-container, paperdoll, and physical-shortcut cells through the same request owner. Its shared physical left-click contract now also matches retail's press-time selection: the selection frame updates on MouseDown before drag promotion, while completed -click/double-click remains the activation boundary. Slice 3's first connected gate exposed +click/double-click remains the activation boundary. The same timing is now +ported for favorite spells through a distinct catalog contract: left press +selects without waiting for release; right-click opens the examination +floaty's authored SpellPanel locally; and spell IDs never enter global object +selection or the status-bar magnifier path. The view projects retail's +name/school/mana/duration/range/description and authored appropriate-formula +component cells, including live missing-component state. Its connected visual +gate is pending. Slice 3's first connected gate exposed three deeper defects, now corrected: the response enum mislabeled ACE's creature bit `0x0100` and dropped monster packets while retaining the busy cursor; the imported 310 x 400 examination layout was incorrectly hosted as diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 47395428..ded8b6d4 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -108,6 +108,12 @@ The same shared ItemList contract now ports retail's left-button-down selection timing across inventory, loot, paperdoll, and physical shortcuts: selection is visible before release or drag promotion, while use/open/equip remains release-completed. +Favorite spells now use the parallel retail catalog contract: MouseDown +selects immediately, right-click opens the examination floaty's authored +SpellPanel locally, and no spell ID enters global object selection or the +status-bar magnifier command. Exact spell fields and the current appropriate +formula's authored component cells are automated; the connected visual gate +is pending. Slice 3's first connected gate found and corrected the real remaining defects: ACE creature flag `0x0100` was mislabeled as a weapon profile and left the appraisal busy reference held, retail's examination UI had been mounted as a diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 16719c6c..9490919b 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -8,7 +8,8 @@ shared-panel mount, and its missing inscription transaction. All three are corrected and live-confirmed. The creature-page follow-up now has retail's ordered stat rows and animated private preview. The follow-up item report and authored 310 x 400 layout correction are implemented; their connected visual -gate is pending. +gate is pending. Favorite-spell press-time selection and right-click local +SpellPanel examination are implemented and await their connected visual gate. **Milestone:** M4 prerequisite/preamble. **Architecture:** retained gameplay UI over shared selection, object, and interaction state. `GameWindow` remains a composition/callback shell. @@ -249,6 +250,16 @@ and the retained green frame update during left-button down, before the three-pixel drag threshold. Target mode is offered first. Opening, using, equipping, looting, and shortcut activation remain completed-click or double-click actions and are suppressed when target mode consumed the press. +Favorite spells now use the parallel non-weenie path: +`gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0` selects the favorite +on left press, while the spell branch of +`UIElement_ItemList::ListenToElementMessage @ 0x004E4D50` opens the authored +SpellPanel locally on right-click. Spell IDs never enter `SelectionState` or +the status-bar magnifier path, and no Appraise/busy transaction is invented. +The view projects exact spell fields and the current appropriate formula +through authored component template `0x1000032E`; its connected visual gate is +pending. The 61-test focused App gate, Release solution build, 3,553 App tests +/ 3 skips, and 7,984 complete-solution tests / 5 skips pass. ## Slice 1 — spell-bar overflow arrows @@ -371,6 +382,11 @@ Named retail references and executable pseudocode are recorded in refresh; closing the examination window must stop refreshes. - Keep Examination open while opening/moving Inventory or Skills and confirm both windows coexist with independent rectangles. +- On the combat spell bar, press and hold a different favorite: its selection + and name should update before release. Right-click a favorite: the same + examination floaty should show the SpellPanel with name, school, mana, + duration, range, description, and component formula. The toolbar magnifier + must not treat the selected spell as an object. - Assess an owned inscribable weapon. Edit and clear its inscription by clicking elsewhere, then reassess it. An item authored by another player is read-only and reports the exact retail permission line when clicked. diff --git a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md index 7f5f0ff6..a591e40f 100644 --- a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md +++ b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md @@ -219,6 +219,85 @@ Outbound character events (`CM_Character`): 0x0224 SetDesiredComponentLevel(componentDid:u32, amount:i32) ``` +### Favorite press selection and local spell examination + +Retail: + +- `gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0` +- `SpellCastSubMenu::SetSelected @ 0x004C5B00` +- `UIElement_ItemList::ListenToElementMessage @ 0x004E4D50` +- `ClientUISystem::ExamineSpell @ 0x00564A70` +- `gmExaminationUI::ExamineSpell @ 0x004B6900` +- `SpellExamineUI::ExamineSpell @ 0x004B6210` +- `SpellExamineUI::DetermineSpellRange @ 0x004AD450` + +A favorite spell is selected on the UIItem's left-button-down message, not on +button release. Double-click remains a completed gesture and casts the spell +that the press already selected. + +```text +favorite UIItem message: + if message parameter == 7: // left press + submenu.SetSelected(spellId) + if message parameter == 10: // completed double click + Cast() + +UIElement_ItemList right click: + if itemId != 0: + select itemId as the global weenie selection + ExamineObject(itemId) + else if spellId != 0: + ClientUISystem.ExamineSpell(spellId) +``` + +The spell branch is deliberately local. It never stores the spell ID in +`ACCWeenieObject::selectedID`, never sends an Appraise request, and never takes +a new UI-busy reference. The toolbar magnifying glass therefore remains an +object-only command; selecting a favorite cannot make it assess that spell. + +The examination floaty is shared between object and spell subviews. Switching +to a spell cancels only an in-flight/current object appraisal: + +```text +gmExaminationUI::ExamineSpell(spellId): + if awaiting object appraisal: + clear pending object + decrement that appraisal's UI-busy reference + clear current object + send Appraise(0) + else if a current object appraisal exists: + clear current object + send Appraise(0) + + SpellExamineUI.ExamineSpell(spellId) + activate SpellPanel + show examination window +``` + +The authored spell subview is part of LayoutDesc `0x2100006B`: + +```text +SpellPanel 0x10000153 +school text 0x1000015E +spell icon 0x1000015F +mana text 0x10000160 +duration text 0x10000161 +range text 0x10000162 +description/components text 0x10000163 +formula list 0x1000032D +formula-cell template 0x1000032E +missing-component overlay 0x10000330 +``` + +Presentation uses the current appropriate formula, preserving its order and +duplicates. Each component icon keeps the authored missing-component overlay +until the player owns that component. Duration is shown as integer seconds +below one minute and integer minutes from one minute upward. Range is +`min(baseConstant + baseModifier * schoolSkill, 75) / 0.9144`, displayed with +one decimal place in yards. School skill IDs are Creature `0x1F`, Item `0x20`, +Life `0x21`, War `0x22`, and Void `0x2B`; an unclassified spell uses the +highest of those five skills. + ## Spellbook and component book Retail: `gmSpellbookUI::PostInit` `0x0048B2B0` and diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index c69eeade..d4637635 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -405,6 +405,39 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory canSend: () => late.Session.IsInWorld); checkpoint(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated); + uint MagicSkillLevel(MagicSchool school) + { + static uint SkillId(MagicSchool value) => value switch + { + MagicSchool.CreatureEnchantment => 0x1Fu, + MagicSchool.ItemEnchantment => 0x20u, + MagicSchool.LifeMagic => 0x21u, + MagicSchool.WarMagic => 0x22u, + MagicSchool.VoidMagic => 0x2Bu, + _ => 0u, + }; + + uint skillId = SkillId(school); + if (skillId != 0u) + return d.LocalPlayer.GetSkill(skillId)?.CurrentLevel ?? 0u; + + uint highest = 0u; + foreach (MagicSchool candidate in new[] + { + MagicSchool.CreatureEnchantment, + MagicSchool.ItemEnchantment, + MagicSchool.LifeMagic, + MagicSchool.WarMagic, + MagicSchool.VoidMagic, + }) + { + highest = Math.Max( + highest, + d.LocalPlayer.GetSkill(SkillId(candidate))?.CurrentLevel ?? 0u); + } + return highest; + } + foreach (IMouse mouse in d.Input.Mice) host.WireMouse(mouse); checkpoint(InteractionRetainedUiCompositionPoint.MouseInputWired); @@ -500,6 +533,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory iconComposer.GetSpellComponentIcon, d.Selection, d.MagicCatalog.GetSpellLevel, + magic.GetExamineComponents, + MagicSkillLevel, guid => d.Selection.Select(guid, SelectionChangeSource.Inventory), guid => late.Session.TryUseItem(guid, d.Log), (tab, position, spellId) => diff --git a/src/AcDream.App/Spells/MagicRuntime.cs b/src/AcDream.App/Spells/MagicRuntime.cs index e1aa83b8..8c6e0b43 100644 --- a/src/AcDream.App/Spells/MagicRuntime.cs +++ b/src/AcDream.App/Spells/MagicRuntime.cs @@ -17,6 +17,12 @@ public sealed record SpellComponentDescriptor( uint Category, uint IconId); +/// One resolved formula cell in retail's spell examination subview. +public readonly record struct SpellExamineComponent( + uint SpellComponentId, + SpellComponentDescriptor Descriptor, + bool Owned); + /// /// App-layer projection of retail's spell, component, and school-focus DAT tables. /// Keeping this catalog outside GameWindow makes the SCID/WCID boundary and @@ -52,6 +58,20 @@ public sealed class MagicCatalog public CoreSpellTable SpellTable { get; } public IReadOnlyDictionary Components { get; } + public bool TryGetComponentBySpellComponentId( + uint spellComponentId, + out SpellComponentDescriptor descriptor) + { + if (_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId) + && Components.TryGetValue(weenieClassId, out SpellComponentDescriptor? found)) + { + descriptor = found; + return true; + } + descriptor = null!; + return false; + } + public bool IsComponentPack(uint weenieClassId) => Components.ContainsKey(weenieClassId); @@ -143,15 +163,43 @@ public sealed class MagicCatalog /// public sealed class MagicRuntime { - private MagicRuntime(MagicCatalog catalog, SpellCastingController casting) + private readonly SpellComponentRequirementService _requirements; + + private MagicRuntime( + MagicCatalog catalog, + SpellCastingController casting, + SpellComponentRequirementService requirements) { Catalog = catalog; Casting = casting; + _requirements = requirements; } public MagicCatalog Catalog { get; } public SpellCastingController Casting { get; } + public IReadOnlyList GetExamineComponents(uint spellId) + { + IReadOnlyList formula = _requirements.GetAppropriateFormula(spellId); + if (formula.Count == 0) + return []; + + var result = new List(formula.Count); + foreach (uint spellComponentId in formula) + { + if (spellComponentId == 0u + || !Catalog.TryGetComponentBySpellComponentId( + spellComponentId, + out SpellComponentDescriptor descriptor)) + continue; + result.Add(new SpellExamineComponent( + spellComponentId, + descriptor, + _requirements.IsComponentOwned(spellComponentId))); + } + return result; + } + public static MagicRuntime Create( MagicCatalog catalog, Spellbook spellbook, @@ -198,7 +246,7 @@ public sealed class MagicRuntime incrementBusy: incrementBusy, canSend: canSend, targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false)); - return new MagicRuntime(catalog, casting); + return new MagicRuntime(catalog, casting, requirements); } public void Reset() => Casting.Reset(); diff --git a/src/AcDream.App/Spells/SpellComponentRequirementService.cs b/src/AcDream.App/Spells/SpellComponentRequirementService.cs index fa1b10f7..e66e7f3b 100644 --- a/src/AcDream.App/Spells/SpellComponentRequirementService.cs +++ b/src/AcDream.App/Spells/SpellComponentRequirementService.cs @@ -75,6 +75,31 @@ public sealed class SpellComponentRequirementService /// (0x00567D50): an infused school or a directly carried school focus uses /// the scarab-only formula; otherwise the account-customized formula wins. /// + public IReadOnlyList GetAppropriateFormula(uint spellId) + { + if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula)) + return []; + uint playerGuid = _playerGuid(); + return GetAppropriateFormula( + formula, + _objects.Get(playerGuid), + playerGuid); + } + + /// + /// Whether the component tracker would leave a formula icon unghosted. + /// The input is a retail spell-component id (SCID), not a weenie class id. + /// + public bool IsComponentOwned(uint spellComponentId) + { + if (!_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId)) + return false; + uint playerGuid = _playerGuid(); + return _objects.Objects.Any(item => + item.WeenieClassId == weenieClassId + && IsOwnedByPlayer(item, playerGuid)); + } + private IReadOnlyList GetAppropriateFormula( SpellFormulaDefinition formula, ClientObject? player, diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 87bca58e..8b22a10e 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -553,6 +553,31 @@ public sealed class ItemInteractionController : IDisposable return true; } + /// + /// Releases only the object-appraisal transaction before the local spell + /// examination subview takes ownership of the shared floaty window. + /// + /// Retail gmExaminationUI::ExamineSpell @ 0x004B6900 decrements the + /// appraisal busy reference when a response is pending, clears both pending + /// and current object ids, and sends CM_Item::Event_Appraise(0). It + /// does not clear unrelated use/inventory busy references. + /// + public void CancelObjectAppraisalForSpell() + { + if (_awaitingAppraisalId == 0u && _currentAppraisalId == 0u) + return; + + if (_awaitingAppraisalId != 0u) + { + _awaitingAppraisalId = 0u; + if (_busyCount > 0) + _busyCount--; + } + _currentAppraisalId = 0u; + _sendExamine?.Invoke(0u); + StateChanged?.Invoke(); + } + /// /// Projects retail gmToolbarUI::HandleSelectionChanged @ 0x004BF380 /// from the live selected object's public description. This is deliberately diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index 178b094b..976cfb74 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -1,4 +1,6 @@ using System.Globalization; +using System.Text; +using AcDream.App.Spells; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; @@ -32,6 +34,13 @@ public sealed class AppraisalUiController : IRetainedPanelController public const uint CreatureDisplayNameId = 0x1000014Eu; public const uint CreatureLevelValueId = 0x1000014Cu; public const uint SpellPanelId = 0x10000153u; + public const uint SpellSchoolTextId = 0x1000015Eu; + public const uint SpellIconId = 0x1000015Fu; + public const uint SpellManaTextId = 0x10000160u; + public const uint SpellDurationTextId = 0x10000161u; + public const uint SpellRangeTextId = 0x10000162u; + public const uint SpellDisplayTextId = 0x10000163u; + public const uint SpellFormulaListId = 0x1000032Du; private const uint TemplateStringProperty = 5u; private const uint CharacterMarkerIntProperty = 0x105u; @@ -51,7 +60,7 @@ public sealed class AppraisalUiController : IRetainedPanelController private readonly Action _closeWindow; private readonly UiElement _itemPanel; private readonly UiElement _creaturePanel; - private readonly UiElement? _spellPanel; + private readonly UiElement _spellPanel; private readonly UiText _title; private readonly UiText _itemText; private readonly UiText? _inscriptionText; @@ -64,6 +73,22 @@ public sealed class AppraisalUiController : IRetainedPanelController private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates; private readonly CreatureDisplayNameResolver _creatureNames; private readonly RetailAppraisalNameResolver _itemNames; + private readonly Func _resolveSpellIcon; + private readonly Func _resolveComponentIcon; + private readonly Func> _spellComponents; + private readonly Func _magicSkill; + private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates; + private readonly UiText _spellSchool; + private readonly UiText _spellMana; + private readonly UiText _spellDuration; + private readonly UiText _spellRange; + private readonly UiText _spellDisplay; + private readonly UiElement _spellIconHost; + private readonly UiElement _spellFormulaHost; + private readonly UiTextureElement _spellIcon; + private readonly List _spellFormulaCells = []; + private readonly float _spellFormulaOriginX; + private readonly float _spellFormulaCellWidth; private AppraisalView _activeView; private uint _itemObjectId; private uint _creatureObjectId; @@ -75,6 +100,7 @@ public sealed class AppraisalUiController : IRetainedPanelController private string _scribeName = string.Empty; private string _oldInscription = string.Empty; private bool _presentationInscribable; + private uint _spellId; private bool _windowVisible; private double _refreshElapsed; private bool _disposed; @@ -93,11 +119,17 @@ public sealed class AppraisalUiController : IRetainedPanelController Action close, UiElement itemPanel, UiElement creaturePanel, + UiElement spellPanel, UiText title, UiText itemText, CreatureAppraisalRowTemplateFactory? creatureRowTemplates, CreatureDisplayNameResolver? creatureNames, - RetailAppraisalNameResolver? itemNames) + RetailAppraisalNameResolver? itemNames, + Func? resolveSpellIcon, + Func? resolveComponentIcon, + Func>? spellComponents, + Func? magicSkill, + SpellExamineComponentTemplateFactory? spellComponentTemplates) { _layout = layout; _objects = objects; @@ -112,7 +144,7 @@ public sealed class AppraisalUiController : IRetainedPanelController _closeWindow = close; _itemPanel = itemPanel; _creaturePanel = creaturePanel; - _spellPanel = layout.FindElement(SpellPanelId); + _spellPanel = spellPanel; _title = title; _itemText = itemText; _creatureRowTemplates = creatureRowTemplates; @@ -120,6 +152,20 @@ public sealed class AppraisalUiController : IRetainedPanelController ?? new CreatureDisplayNameResolver( new Dictionary()); _itemNames = itemNames ?? RetailAppraisalNameResolver.Empty; + _resolveSpellIcon = resolveSpellIcon ?? (_ => 0u); + _resolveComponentIcon = resolveComponentIcon ?? (_ => 0u); + _spellComponents = spellComponents ?? (_ => []); + _magicSkill = magicSkill ?? (_ => 0u); + _spellComponentTemplates = spellComponentTemplates; + _spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!; + _spellMana = (UiText)layout.FindElement(SpellManaTextId)!; + _spellDuration = (UiText)layout.FindElement(SpellDurationTextId)!; + _spellRange = (UiText)layout.FindElement(SpellRangeTextId)!; + _spellDisplay = (UiText)layout.FindElement(SpellDisplayTextId)!; + _spellIconHost = layout.FindElement(SpellIconId)!; + _spellFormulaHost = layout.FindElement(SpellFormulaListId)!; + _spellFormulaOriginX = _spellFormulaHost.Left; + _spellFormulaCellWidth = _spellFormulaHost.Width; _inscriptionText = layout.FindElement(InscriptionTextId) as UiText; _inscriptionField = layout.FindElement(InscriptionTextId) as UiField; _signature = layout.FindElement(SignatureTextId) as UiText; @@ -164,6 +210,22 @@ public sealed class AppraisalUiController : IRetainedPanelController _inscriptionBackground.ClickThrough = false; } + if (_spellIconHost is UiDatElement spellIconDat) + spellIconDat.MediaVisible = false; + _spellIcon = new UiTextureElement + { + Width = _spellIconHost.Width, + Height = _spellIconHost.Height, + Anchors = AnchorEdges.Left | AnchorEdges.Top + | AnchorEdges.Right | AnchorEdges.Bottom, + }; + _spellIconHost.AddChild(_spellIcon); + ConfigureSpellText(_spellSchool); + ConfigureSpellText(_spellMana); + ConfigureSpellText(_spellDuration); + ConfigureSpellText(_spellRange); + ConfigureSpellText(_spellDisplay, wrap: true); + if (creatureRowTemplates is not null && layout.FindElement(CreatureStatsListId) is { } statsHost && layout.FindElement(CreatureExtraListId) is { } extraHost @@ -184,6 +246,11 @@ public sealed class AppraisalUiController : IRetainedPanelController } _selection.Changed += HandleSelectionChanged; + _objects.ObjectAdded += HandleSpellComponentObjectChanged; + _objects.ObjectMoved += HandleSpellComponentObjectMoved; + _objects.ObjectRemoved += HandleSpellComponentObjectChanged; + _objects.StackSizeUpdated += HandleSpellComponentObjectChanged; + _objects.Cleared += HandleSpellComponentObjectsCleared; SetActiveView(AppraisalView.Item); } @@ -204,7 +271,12 @@ public sealed class AppraisalUiController : IRetainedPanelController Action close, CreatureAppraisalRowTemplateFactory? creatureRowTemplates = null, CreatureDisplayNameResolver? creatureNames = null, - RetailAppraisalNameResolver? itemNames = null) + RetailAppraisalNameResolver? itemNames = null, + Func? resolveSpellIcon = null, + Func? resolveComponentIcon = null, + Func>? spellComponents = null, + Func? magicSkill = null, + SpellExamineComponentTemplateFactory? spellComponentTemplates = null) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(objects); @@ -220,8 +292,16 @@ public sealed class AppraisalUiController : IRetainedPanelController if (layout.FindElement(ItemPanelId) is not { } itemPanel || layout.FindElement(CreaturePanelId) is not { } creaturePanel + || layout.FindElement(SpellPanelId) is not { } spellPanel || layout.FindElement(TitleId) is not UiText title - || layout.FindElement(ItemTextId) is not UiText itemText) + || layout.FindElement(ItemTextId) is not UiText itemText + || layout.FindElement(SpellSchoolTextId) is not UiText + || layout.FindElement(SpellManaTextId) is not UiText + || layout.FindElement(SpellDurationTextId) is not UiText + || layout.FindElement(SpellRangeTextId) is not UiText + || layout.FindElement(SpellDisplayTextId) is not UiText + || layout.FindElement(SpellIconId) is null + || layout.FindElement(SpellFormulaListId) is null) return null; return new AppraisalUiController( @@ -238,11 +318,79 @@ public sealed class AppraisalUiController : IRetainedPanelController close, itemPanel, creaturePanel, + spellPanel, title, itemText, creatureRowTemplates, creatureNames, - itemNames); + itemNames, + resolveSpellIcon, + resolveComponentIcon, + spellComponents, + magicSkill, + spellComponentTemplates); + } + + /// + /// Opens retail's local spell-examination subview. Unlike object appraisal, + /// this performs no network request and never changes the globally selected + /// object. Source: gmExaminationUI::ExamineSpell @ 0x004B6900 and + /// SpellExamineUI::ExamineSpell @ 0x004B6210. + /// + public bool ExamineSpell(uint spellId) + { + if (spellId == 0u + || !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata)) + return false; + + _interaction.CancelObjectAppraisalForSpell(); + _spellId = spellId; + _titleValue = metadata.Name; + SetSpellText(_spellSchool, $"School: {metadata.School}"); + + string mana = metadata.ManaCost > 0 + ? metadata.ManaCost.ToString(CultureInfo.InvariantCulture) + : "???"; + if (metadata.ManaModifier > 0u) + mana += $" + {metadata.ManaModifier.ToString(CultureInfo.InvariantCulture)} per target"; + SetSpellText(_spellMana, $"Mana: {mana}"); + + string duration = string.Empty; + if (metadata.Duration > 0f && metadata.Duration != -1f) + { + uint displayed = metadata.Duration >= 60f + ? (uint)(metadata.Duration / 60f) + : (uint)metadata.Duration; + duration = metadata.Duration >= 60f + ? $"Duration: {displayed.ToString(CultureInfo.InvariantCulture)} min." + : $"Duration: {displayed.ToString(CultureInfo.InvariantCulture)} sec."; + } + SetSpellText(_spellDuration, duration); + + float range = MathF.Min( + metadata.BaseRangeConstant + + metadata.BaseRangeModifier * _magicSkill(metadata.SchoolId), + 75f); + SetSpellText( + _spellRange, + range > 0f + ? string.Format( + CultureInfo.InvariantCulture, + "Range: {0:F1} yds.", + range / 0.9144f) + : string.Empty); + + IReadOnlyList components = + _spellComponents(spellId); + SetSpellText(_spellDisplay, BuildSpellDisplay(metadata, components)); + _spellDisplay.Scroll.SetScrollY(0); + _spellIcon.Texture = _resolveSpellIcon(spellId); + RebuildSpellFormula(components); + + SetActiveView(AppraisalView.Spell); + _refreshElapsed = 0; + _show(); + return true; } /// @@ -335,7 +483,15 @@ public sealed class AppraisalUiController : IRetainedPanelController _itemObjectId = 0; _creatureObjectId = 0; _characterObjectId = 0; + _spellId = 0u; _refreshElapsed = 0; + _spellIcon.Texture = 0u; + SetSpellText(_spellSchool, string.Empty); + SetSpellText(_spellMana, string.Empty); + SetSpellText(_spellDuration, string.Empty); + SetSpellText(_spellRange, string.Empty); + SetSpellText(_spellDisplay, string.Empty); + ClearSpellFormula(); SetActiveView(AppraisalView.Item); ClearCreatureText(); } @@ -579,6 +735,75 @@ public sealed class AppraisalUiController : IRetainedPanelController scrollbar.Model = text.Scroll; } + private static void ConfigureSpellText(UiText text, bool wrap = false) + { + text.PreserveEndOnLayout = false; + text.ClickThrough = true; + if (wrap) + { + text.OneLine = false; + text.VerticalJustify = VJustify.Top; + } + } + + private static void SetSpellText(UiText text, string value) + => text.LinesProvider = () => IndicatorDetailText.Shape(text, value); + + private static string BuildSpellDisplay( + SpellMetadata metadata, + IReadOnlyList components) + { + var display = new StringBuilder(metadata.Description); + if (components.Count == 0) + return display.ToString(); + + display.Append("\nCOMPONENTS:"); + foreach (SpellExamineComponent component in components) + display.Append("\n ").Append(component.Descriptor.Name); + return display.ToString(); + } + + private void RebuildSpellFormula( + IReadOnlyList components) + { + ClearSpellFormula(); + if (_spellComponentTemplates is null || components.Count == 0) + { + _spellFormulaHost.Width = _spellFormulaCellWidth; + _spellFormulaHost.Left = _spellFormulaOriginX; + return; + } + + float cellWidth = _spellFormulaCellWidth; + _spellFormulaHost.Left = + _spellFormulaOriginX - cellWidth * 0.5f * (components.Count - 1); + _spellFormulaHost.Width = cellWidth * components.Count; + _spellFormulaHost.ResetAnchorCapture(); + for (int i = 0; i < components.Count; i++) + { + SpellExamineComponent component = components[i]; + UiElement cell = _spellComponentTemplates.Create( + _resolveComponentIcon(component.Descriptor.WeenieClassId), + component.Owned); + cell.LayoutPolicy = null; + cell.Anchors = AnchorEdges.Left | AnchorEdges.Top; + cell.Left = i * cellWidth; + cell.Top = 0f; + _spellFormulaHost.AddChild(cell); + _spellFormulaCells.Add(cell); + } + } + + private void ClearSpellFormula() + { + foreach (UiElement cell in _spellFormulaCells) + _spellFormulaHost.RemoveChild(cell); + _spellFormulaCells.Clear(); + _spellFormulaHost.Left = _spellFormulaOriginX; + _spellFormulaHost.Width = _spellFormulaCellWidth; + _spellFormulaHost.ResetAnchorCapture(); + } + private void SetText(uint elementId, string value, bool scrollable = false) { if (_layout.FindElement(elementId) is not UiText text) @@ -623,8 +848,7 @@ public sealed class AppraisalUiController : IRetainedPanelController _activeView = view; _itemPanel.Visible = view == AppraisalView.Item; _creaturePanel.Visible = view is AppraisalView.Creature or AppraisalView.Character; - if (_spellPanel is not null) - _spellPanel.Visible = false; + _spellPanel.Visible = view == AppraisalView.Spell; UiElement? creatureInfo = _layout.FindElement(0x1000014Du); UiElement? characterInfo = _layout.FindElement(0x1000014Fu); @@ -704,12 +928,39 @@ public sealed class AppraisalUiController : IRetainedPanelController _closeWindow(); } + private void HandleSpellComponentObjectChanged(ClientObject _) + => RefreshSpellComponents(); + + private void HandleSpellComponentObjectMoved(ClientObjectMove _) + => RefreshSpellComponents(); + + private void HandleSpellComponentObjectsCleared() + => RefreshSpellComponents(); + + private void RefreshSpellComponents() + { + if (_activeView != AppraisalView.Spell + || _spellId == 0u + || !_spellbook.TryGetMetadata(_spellId, out SpellMetadata metadata)) + return; + + IReadOnlyList components = + _spellComponents(_spellId); + SetSpellText(_spellDisplay, BuildSpellDisplay(metadata, components)); + RebuildSpellFormula(components); + } + public void Dispose() { if (_disposed) return; _disposed = true; _selection.Changed -= HandleSelectionChanged; + _objects.ObjectAdded -= HandleSpellComponentObjectChanged; + _objects.ObjectMoved -= HandleSpellComponentObjectMoved; + _objects.ObjectRemoved -= HandleSpellComponentObjectChanged; + _objects.StackSizeUpdated -= HandleSpellComponentObjectChanged; + _objects.Cleared -= HandleSpellComponentObjectsCleared; if (_close is not null) _close.OnClick = null; if (_inscriptionField is not null) @@ -720,6 +971,8 @@ public sealed class AppraisalUiController : IRetainedPanelController } if (_inscriptionBackground is not null) _inscriptionBackground.OnClick = null; + ClearSpellFormula(); + _spellIconHost.RemoveChild(_spellIcon); foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType()) if (ReferenceEquals(scrollbar.Model, _itemText.Scroll) || (_inscriptionText is not null @@ -735,4 +988,5 @@ public enum AppraisalView Item, Creature, Character, + Spell, } diff --git a/src/AcDream.App/UI/Layout/SpellExamineComponentTemplateFactory.cs b/src/AcDream.App/UI/Layout/SpellExamineComponentTemplateFactory.cs new file mode 100644 index 00000000..a92c8ad5 --- /dev/null +++ b/src/AcDream.App/UI/Layout/SpellExamineComponentTemplateFactory.cs @@ -0,0 +1,95 @@ +using AcDream.Content; +using DatReaderWriter; + +namespace AcDream.App.UI.Layout; + +/// +/// Instantiates the authored formula-icon template used by retail +/// SpellExamineUI::ExamineSpell @ 0x004B6210. The controller replaces +/// the template root's placeholder image with the resolved component icon while +/// retaining child 0x10000330 as the missing-component overlay. +/// +public sealed class SpellExamineComponentTemplateFactory +{ + public const uint TemplateId = 0x1000032Eu; + public const uint MissingOverlayId = 0x10000330u; + + private readonly ElementInfo _template; + private readonly Func _resolveSprite; + private readonly UiDatFont? _defaultFont; + private readonly IReadOnlyDictionary _fonts; + + public SpellExamineComponentTemplateFactory( + ElementInfo template, + Func resolveSprite, + UiDatFont? defaultFont, + IReadOnlyDictionary? fonts = null) + { + _template = template ?? throw new ArgumentNullException(nameof(template)); + _resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite)); + _defaultFont = defaultFont; + _fonts = fonts ?? new Dictionary(); + } + + public static SpellExamineComponentTemplateFactory? TryLoad( + IDatReaderWriter dats, + Func resolveSprite, + UiDatFont? defaultFont, + Func? resolveFont) + { + ElementInfo? template = LayoutImporter.ImportInfos( + dats, + AppraisalUiController.LayoutId, + TemplateId); + if (template is null) + return null; + + var fonts = new Dictionary(); + CaptureFonts(template, resolveFont, fonts); + return new SpellExamineComponentTemplateFactory( + template, + resolveSprite, + defaultFont, + fonts); + } + + public UiElement Create(uint iconTexture, bool owned) + { + ImportedLayout content = LayoutImporter.Build( + _template, + _resolveSprite, + _defaultFont, + did => _fonts.TryGetValue(did, out UiDatFont? font) + ? font + : _defaultFont); + UiElement root = content.Root; + if (root is UiDatElement datRoot) + datRoot.MediaVisible = false; + + var icon = new UiTextureElement + { + Width = root.Width, + Height = root.Height, + Anchors = AnchorEdges.Left | AnchorEdges.Top + | AnchorEdges.Right | AnchorEdges.Bottom, + Texture = iconTexture, + ZOrder = int.MinValue / 2, + }; + root.AddChild(icon); + + if (content.FindElement(MissingOverlayId) is { } missing) + missing.Visible = !owned; + return root; + } + + private static void CaptureFonts( + ElementInfo info, + Func? resolveFont, + Dictionary fonts) + { + if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid)) + fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid); + foreach (ElementInfo child in info.Children) + CaptureFonts(child, resolveFont, fonts); + } +} diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs index c7b1206c..5e1ed6df 100644 --- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs +++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs @@ -44,6 +44,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController private readonly Func _resolveSpellIcon; private readonly Func _resolveItemDragIcon; private readonly Action _useItem; + private readonly Action? _examineSpell; private readonly Action? _addFavorite; private readonly Action? _removeFavorite; private readonly UiShortcutDigitGraphics? _shortcutDigits; @@ -74,6 +75,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController Func resolveSpellIcon, Func resolveItemDragIcon, Action useItem, + Action? examineSpell, SelectionState selection, Action? addFavorite, Action? removeFavorite, @@ -94,6 +96,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController _resolveSpellIcon = resolveSpellIcon; _resolveItemDragIcon = resolveItemDragIcon; _useItem = useItem; + _examineSpell = examineSpell; _addFavorite = addFavorite; _removeFavorite = removeFavorite; _shortcutDigits = shortcutDigits; @@ -120,7 +123,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController for (int i = 0; i < _lists.Length; i++) { - if (_lists[i] is not { } list || _scrollbars[i] is not { } scrollbar) + if (_lists[i] is not { } list) + continue; + list.PrimaryCatalogEntryPressed = SelectSpell; + list.ExamineCatalogEntryRequested = _examineSpell; + if (_scrollbars[i] is not { } scrollbar) continue; list.HorizontalScroll = true; scrollbar.Horizontal = true; @@ -161,7 +168,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController Action? addFavorite, Action? removeFavorite, UiShortcutDigitGraphics? shortcutDigits = null, - uint emptySlotSprite = 0u) + uint emptySlotSprite = 0u, + Action? examineSpell = null) { if (layout.FindElement(CastButtonId) is not UiButton cast || layout.FindElement(EndowmentId) is not { } endowmentHost) @@ -185,7 +193,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController return new SpellcastingUiController( layout, spellbook, casting, objects, playerGuid, resolveSpellIcon, - resolveItemDragIcon, useItem, selection, + resolveItemDragIcon, useItem, examineSpell, selection, addFavorite, removeFavorite, tabs, groups, lists, scrollbars, cast, endowmentHost, shortcutDigits, emptySlotSprite); @@ -357,8 +365,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController DropSpellbookShortcut(shortcut, targetTab, position); }, }; - slot.Clicked = () => SelectSpell(id); - slot.DoubleClicked = () => { SelectSpell(id); CastSelected(); }; + // gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0 + // selects on left press (message parameter 7). A completed + // double click casts, but release is not required merely to + // change the current spell. + slot.DoubleClicked = CastSelected; list.AddItem(slot); } } @@ -557,6 +568,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController _objects.ObjectRemoved -= OnObjectChanged; _objects.Cleared -= OnObjectsCleared; foreach (UiElement tab in _tabs) SetClick(tab, null); + foreach (UiItemList? list in _lists) + { + if (list is null) continue; + list.PrimaryCatalogEntryPressed = null; + list.ExamineCatalogEntryRequested = null; + } _cast.OnClick = null; } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index c6f7cd99..7331e345 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -60,6 +60,8 @@ public sealed record MagicRuntimeBindings( Func ResolveComponentIcon, SelectionState Selection, Func SpellLevel, + Func> SpellComponents, + Func MagicSkill, Action SelectObject, Action UseItem, Action AddFavorite, @@ -831,7 +833,8 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Magic.AddFavorite, _bindings.Magic.RemoveFavorite, LoadShortcutDigitGraphics(), - favoriteEmptySprite); + favoriteEmptySprite, + examineSpell: spellId => AppraisalController?.ExamineSpell(spellId)); if (spellcasting is null) Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073."); @@ -965,6 +968,7 @@ public sealed class RetailUiRuntime : IDisposable { ImportedLayout? layout; CreatureAppraisalRowTemplateFactory? creatureRows; + SpellExamineComponentTemplateFactory? spellComponentTemplates; CreatureDisplayNameResolver? creatureNames; RetailAppraisalNameResolver? itemNames; lock (_bindings.Assets.DatLock) @@ -981,6 +985,12 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveSprite, _bindings.Assets.DefaultFont, _bindings.Assets.ResolveFont); + spellComponentTemplates = + SpellExamineComponentTemplateFactory.TryLoad( + _bindings.Assets.Dats, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); creatureNames = CreatureDisplayNameResolver.Load( _bindings.Assets.Dats); itemNames = RetailAppraisalNameResolver.Load( @@ -1008,7 +1018,12 @@ public sealed class RetailUiRuntime : IDisposable close: () => CloseWindow(WindowNames.Examination), creatureRowTemplates: creatureRows, creatureNames: creatureNames, - itemNames: itemNames); + itemNames: itemNames, + resolveSpellIcon: _bindings.Magic.ResolveSpellIcon, + resolveComponentIcon: _bindings.Magic.ResolveComponentIcon, + spellComponents: _bindings.Magic.SpellComponents, + magicSkill: _bindings.Magic.MagicSkill, + spellComponentTemplates: spellComponentTemplates); if (controller is null) { Console.WriteLine( diff --git a/src/AcDream.App/UI/UiCatalogSlot.cs b/src/AcDream.App/UI/UiCatalogSlot.cs index fc59a72e..a6d36372 100644 --- a/src/AcDream.App/UI/UiCatalogSlot.cs +++ b/src/AcDream.App/UI/UiCatalogSlot.cs @@ -58,6 +58,9 @@ public sealed class UiCatalogSlot : UiItemSlot switch (e.Type) { case UiEventType.MouseDown: + if (EntryId != 0u + && FindList() is { PrimaryCatalogEntryPressed: { } pressed }) + pressed(EntryId); return true; case UiEventType.Click: Clicked?.Invoke(); @@ -65,6 +68,11 @@ public sealed class UiCatalogSlot : UiItemSlot case UiEventType.DoubleClick: DoubleClicked?.Invoke(); return true; + case UiEventType.RightClick: + if (EntryId != 0u + && FindList() is { ExamineCatalogEntryRequested: { } examine }) + examine(EntryId); + return true; case UiEventType.DragBegin: if (e.Payload is not null) DragBegan?.Invoke(e.Payload); return true; diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index bf48e6d2..d96b3654 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -96,6 +96,25 @@ public sealed class UiItemList : UiElement /// public Action? ExamineItemRequested { get; set; } + /// + /// Non-weenie catalog-entry left-button-down command supplied by the owning + /// panel. Spell favorites use this distinct path so a spell id is never + /// published as the globally selected object GUID. + /// + /// Retail gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0 + /// handles UIItem message parameter 7 (left press) by calling + /// SpellCastSubMenu::SetSelected. + /// + public Action? PrimaryCatalogEntryPressed { get; set; } + + /// + /// Local examination command for non-weenie catalog entries. Retail + /// UIElement_ItemList::ListenToElementMessage @ 0x004E4D50 + /// routes right-clicked spellID values to + /// ClientUISystem::ExamineSpell without changing the selected object. + /// + public Action? ExamineCatalogEntryRequested { get; set; } + private uint _cellEmptySprite; /// Empty-slot sprite for THIS list's cells, resolved from the dat cell template /// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index b77acb86..898c14af 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -180,7 +180,7 @@ public class UiItemSlot : UiElement public override bool IsDragSource => ItemId != 0; /// Walk up to the containing (the drop handler owner). - private UiItemList? FindList() + protected UiItemList? FindList() { UiElement? e = Parent; while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; } diff --git a/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs b/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs index 0f75d6e5..cf449c0b 100644 --- a/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs +++ b/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs @@ -165,6 +165,39 @@ public sealed class SpellComponentRequirementServiceTests Assert.False(service.HasRequiredComponents(50u)); } + [Fact] + public void ExaminationFormulaPreservesOrderAndReportsNestedOwnership() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" }); + objects.AddOrUpdate(new ClientObject + { + ObjectId = 2u, + ContainerId = 1u, + Type = ItemType.Container, + }); + objects.AddOrUpdate(new ClientObject + { + ObjectId = 3u, + WeenieClassId = 101u, + ContainerId = 2u, + }); + var service = Service( + objects, + [10u, 11u, 10u], + new Dictionary + { + [10u] = 101u, + [11u] = 102u, + }); + + Assert.Equal( + new uint[] { 10u, 11u, 10u }, + service.GetAppropriateFormula(50u)); + Assert.True(service.IsComponentOwned(10u)); + Assert.False(service.IsComponentOwned(11u)); + } + private static SpellComponentRequirementService Service( ClientObjectTable objects, IReadOnlyList components, diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs index 4dafc157..d5452b3d 100644 --- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -245,6 +245,30 @@ public class DragDropSpineTests return (root, list, list.Cell); } + private static (UiRoot root, UiItemList list, UiCatalogSlot cell) + RootWithCatalogSlot(uint entryId) + { + var root = new UiRoot { Width = 800, Height = 600 }; + var list = new UiItemList(_ => (1u, 1, 1)) + { + Left = 0, + Top = 0, + Width = 32, + Height = 32, + }; + list.Flush(); + var cell = new UiCatalogSlot + { + EntryId = entryId, + Width = 32, + Height = 32, + SpriteResolve = _ => (1u, 1, 1), + }; + list.AddItem(cell); + root.AddChild(list); + return (root, list, cell); + } + [Fact] public void BeginDrag_arms_whenPayloadNonNull() { @@ -359,6 +383,32 @@ public class DragDropSpineTests Assert.Null(root.DragSource); } + [Fact] + public void CatalogEntryPress_selectsBeforeRelease_withoutForgingItemIdentity() + { + var (root, list, cell) = RootWithCatalogSlot(42u); + var selected = new List(); + list.PrimaryCatalogEntryPressed = selected.Add; + + root.OnMouseDown(UiMouseButton.Left, 10, 10); + + Assert.Equal(new uint[] { 42u }, selected); + Assert.Equal(0u, cell.ItemId); + } + + [Fact] + public void CatalogEntryRightClick_requestsLocalCatalogExamination() + { + var (root, list, _) = RootWithCatalogSlot(42u); + var examined = new List(); + list.ExamineCatalogEntryRequested = examined.Add; + + root.OnMouseDown(UiMouseButton.Right, 10, 10); + root.OnMouseUp(UiMouseButton.Right, 10, 10); + + Assert.Equal(new uint[] { 42u }, examined); + } + [Fact] public void CompletedDrag_doesNotFireUse() { diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index 93a1bc73..bb05f10d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.Spells; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Combat; @@ -688,6 +689,103 @@ public sealed class AppraisalUiControllerTests mounted => mounted.WindowName == WindowNames.Examination); } + [Fact] + public void SpellExamination_UsesAuthoredLocalSubviewWithoutSelectingOrAppraisingSpellId() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Selected Drudge", + Type = ItemType.Creature, + }); + var sent = new List(); + using var interaction = NewInteraction(objects, sent); + var selection = new SelectionState(); + selection.Select(ObjectId, SelectionChangeSource.World); + SpellMetadata metadata = ExaminedSpell(); + var spellbook = new Spellbook(SpellTable.Create([metadata])); + var components = new[] + { + new SpellExamineComponent( + 10u, + new SpellComponentDescriptor(100u, "Lead Scarab", 0u, 0x06000010u), + Owned: true), + new SpellExamineComponent( + 11u, + new SpellComponentDescriptor(101u, "Malar Herb", 1u, 0x06000011u), + Owned: false), + }; + int shown = 0; + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => shown++, + () => { }, + selection: selection, + spellbook: spellbook, + resolveSpellIcon: id => id + 1_000u, + spellComponents: _ => components, + magicSkill: _ => 200u)!; + + Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId)); + Assert.Equal(1, interaction.BusyCount); + + Assert.True(controller.ExamineSpell(metadata.SpellId)); + + Assert.Equal(AppraisalView.Spell, controller.ActiveView); + Assert.Equal(1, shown); + Assert.Equal(ObjectId, selection.SelectedObjectId); + Assert.Equal(new uint[] { ObjectId, 0u }, sent); + Assert.Equal(0, interaction.BusyCount); + Assert.Equal(0u, controller.CurrentObjectId); + Assert.True(layout.FindElement(AppraisalUiController.SpellPanelId)!.Visible); + Assert.False(layout.FindElement(AppraisalUiController.ItemPanelId)!.Visible); + Assert.False(layout.FindElement(AppraisalUiController.CreaturePanelId)!.Visible); + + AssertSpellText( + layout, + AppraisalUiController.TitleId, + "Incantation of Test"); + AssertSpellText( + layout, + AppraisalUiController.SpellSchoolTextId, + "School: War Magic"); + AssertSpellText( + layout, + AppraisalUiController.SpellManaTextId, + "Mana: 50 + 14 per target"); + AssertSpellText( + layout, + AppraisalUiController.SpellDurationTextId, + "Duration: 1 min."); + AssertSpellText( + layout, + AppraisalUiController.SpellRangeTextId, + "Range: 82.0 yds."); + string display = string.Join( + '\n', + ((UiText)layout.FindElement( + AppraisalUiController.SpellDisplayTextId)!) + .LinesProvider() + .Select(line => line.Text)); + Assert.Contains("A projected retail spell.", display); + Assert.Contains("COMPONENTS:", display); + Assert.Contains("Lead Scarab", display); + Assert.Contains("Malar Herb", display); + + UiElement iconHost = layout.FindElement( + AppraisalUiController.SpellIconId)!; + UiTextureElement icon = Assert.Single( + iconHost.Children.OfType()); + Assert.Equal(metadata.SpellId + 1_000u, icon.Texture); + } + private static AppraisalUiController? Bind( ImportedLayout layout, ClientObjectTable objects, @@ -700,14 +798,20 @@ public sealed class AppraisalUiControllerTests CreatureAppraisalRowTemplateFactory? creatureRows = null, CreatureDisplayNameResolver? creatureNames = null, SelectionState? selection = null, - RetailAppraisalNameResolver? itemNames = null) + RetailAppraisalNameResolver? itemNames = null, + Spellbook? spellbook = null, + Func? resolveSpellIcon = null, + Func? resolveComponentIcon = null, + Func>? spellComponents = null, + Func? magicSkill = null, + SpellExamineComponentTemplateFactory? spellComponentTemplates = null) => AppraisalUiController.Bind( layout, objects, interaction, selection ?? new SelectionState(), combat, - new Spellbook(), + spellbook ?? new Spellbook(), () => "Tester", (objectId, text) => inscriptions.Add((objectId, text)), messages.Add, @@ -715,7 +819,12 @@ public sealed class AppraisalUiControllerTests close, creatureRows, creatureNames, - itemNames); + itemNames, + resolveSpellIcon, + resolveComponentIcon, + spellComponents, + magicSkill, + spellComponentTemplates); private static ItemInteractionController NewInteraction( ClientObjectTable objects, @@ -749,4 +858,48 @@ public sealed class AppraisalUiControllerTests ArmorEnchantments: null, WeaponEnchantments: null, ResistEnchantments: null); + + private static void AssertSpellText( + ImportedLayout layout, + uint elementId, + string expected) + { + UiText text = Assert.IsType(layout.FindElement(elementId)); + Assert.Equal( + expected, + string.Join('\n', text.LinesProvider().Select(line => line.Text))); + } + + private static SpellMetadata ExaminedSpell() + => new( + SpellId: 42u, + Name: "Incantation of Test", + School: "War Magic", + Family: 1u, + IconId: 0x06001234u, + SpellWords: "Malar Aether", + Duration: 90f, + ManaCost: 50, + IsDebuff: false, + IsFellowship: false, + Description: "A projected retail spell.", + SortKey: 0, + Difficulty: 0, + Flags: 0u, + Generation: 6, + IsFastWindup: false, + IsOffensive: true, + IsUntargeted: false, + Speed: 0f, + CasterEffect: 0u, + TargetEffect: 0u, + TargetMask: 0u, + SpellType: 0) + { + SchoolId = MagicSchool.WarMagic, + BaseRangeConstant = 40f, + BaseRangeModifier = 0.25f, + ManaModifier = 14u, + FormulaComponents = [10u, 11u], + }; } diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs index 04c454c2..c7713d01 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs @@ -280,6 +280,45 @@ public sealed class SpellcastingUiControllerTests Assert.Empty(used); } + [Fact] + public void FavoritePressSelectsImmediately_AndRightClickExaminesLocally() + { + ImportedLayout layout = LayoutImporter.Build( + FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); + var spellbook = new Spellbook(); + spellbook.OnSpellLearned(42u, 1f); + spellbook.OnSpellLearned(43u, 1f); + spellbook.SetFavorite(0, 0, 42u); + spellbook.SetFavorite(0, 1, 43u); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" }); + var selection = new SelectionState(); + var examined = new List(); + using SpellcastingUiController controller = Bind( + layout, + spellbook, + objects, + _ => { }, + selection: selection, + examineSpell: examined.Add)!; + UiElement group = layout.FindElement(0x100000AAu)!; + UiItemList list = Descendants(group).OfType().First(); + UiCatalogSlot first = Assert.IsType(list.GetItem(0)); + UiCatalogSlot second = Assert.IsType(list.GetItem(1)); + Assert.True(first.Selected); + + second.OnEvent(new UiEvent(0u, second, UiEventType.MouseDown)); + + Assert.False(first.Selected); + Assert.True(second.Selected); + Assert.Null(selection.SelectedObjectId); + + second.OnEvent(new UiEvent(0u, second, UiEventType.RightClick)); + + Assert.Equal(new uint[] { 43u }, examined); + Assert.Null(selection.SelectedObjectId); + } + private static SpellcastingUiController? Bind( ImportedLayout layout, Spellbook spellbook, @@ -287,7 +326,9 @@ public sealed class SpellcastingUiControllerTests Action useItem, Action? addFavorite = null, UiShortcutDigitGraphics? shortcutDigits = null, - uint emptySlotSprite = 0u) + uint emptySlotSprite = 0u, + SelectionState? selection = null, + Action? examineSpell = null) { var casting = new SpellCastingController( spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { }); @@ -296,11 +337,12 @@ public sealed class SpellcastingUiControllerTests spellId => spellId, item => item.ObjectId, useItem, - new SelectionState(), + selection ?? new SelectionState(), addFavorite ?? ((_, _, _) => { }), (_, _) => { }, shortcutDigits, - emptySlotSprite); + emptySlotSprite, + examineSpell); } private static void ApplyAnchors(UiElement parent)