diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 18a42dc9..46cb90d8 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -531,7 +531,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **Missile/portal VFX campaign Steps 0–5 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary. - **✓ M2 local attack receive funnel (live-gated 2026-07-15).** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. - **✓ M2 basic retail combat bar (live-gated 2026-07-15).** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. -- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, Magic-scoped retail keys, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. Exact DAT icons and server-persisted filters/desired components are retained. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`. +- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, Magic-scoped retail keys, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. The spellbook now resolves retail UIItem prototype `0x10000343` for its one-column icon/name/separator rows and selected overlay, binds the authored scrollbar, ports exact filters/display ordering, drags learned spells into the open favorite bar without mutating their source, and reuses `RetailDialogFactory` for server-authoritative spell deletion (`CM_Magic::Event_RemoveSpell`, `0x01A8`). `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. Exact DAT icons and server-persisted filters/desired components are retained. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`. - **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ CORRECTIVE PORT — live character experience qualities** (2026-07-13, #217; connected gate passed). Added retail `PrivateUpdatePropertyInt64 (0x02CF)` so Total Experience, the level-progress meter, and Skills' Unassigned Experience refresh from the same authoritative server update; Total XP value alignment now matches the authored right-justified layout. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 3ce1cddf..34389ab9 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -6,8 +6,9 @@ LANDED 2026-07-15.** The complete connected melee/missile, death/loot, inventory, and item-giving demo is user-gated. The shared projectile/effect foundation is hardened through Step 9 and its single-client visual gate passes. -Next is the F.4/L.1d retail cast-lifecycle gap audit, then F.5 spellbook and -active enchantments. Carried: the two-client portal/observer VFX gate, +Next is the connected F.5 spellbook/favorite-bar/delete visual gate, followed +by cast/enchantment presentation and the two-client portal/observer VFX gate. +Carried: #145-residual, #116 slide-response, R6/TS-42, and Track MP0. --- @@ -649,6 +650,12 @@ The first connected gate exposed and corrected two retained-import defects: spellbook tabs are authored stateful text controls (not buttons), and retail text starts with zero margins. Real-DAT fixtures now pin spellbook binding, Closed/Open child-state propagation, and Magic-tab strings I through VIII. +The follow-up spellbook slice now clones the DAT-authored one-column +spell-shortcut presentation (icon, name, separator, selected overlay), binds +its authored scrollbar, ports exact school/level filter masks and stable +display-order sorting, carries learned spells into the open favorite bar, and +routes Delete through the shared retail confirmation factory plus +server-authoritative `CM_Magic::Event_RemoveSpell (0x01A8)` response path. **Phases to ship:** - **F.4** — Spell cast state machine (buffs + recalls first, projectile 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 86cb58e3..276aec96 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 @@ -240,6 +240,105 @@ sorts by `CSpellBase::_display_order`; school and level buttons apply the server-persisted filter bitfield. Component rows use the installed component table and the server-persisted desired purchase amounts. +### Spellbook row, selection, filtering, dragging, and deletion + +Named-retail anchors: + +- `gmSpellbookUI::IsFilteredOut @ 0x0048AEB0` +- `gmSpellbookUI::GetSortedInsertionPlace @ 0x0048B440` +- `gmSpellbookUI::SetSelected @ 0x0048B540` +- `gmSpellbookUI::UpdateFilter @ 0x0048B5E0` +- `gmSpellbookUI::AddSpell @ 0x0048B7A0` +- `gmSpellbookUI::DeleteSpellDialogCallback @ 0x0048BC10` +- `gmSpellbookUI::DeleteSpell @ 0x0048BD50` +- `gmSpellbookUI::ListenToElementMessage @ 0x0048BFD0` +- `UIElement_UIItem::Init_UIItem_Spell_Shortcut @ 0x004E1140` +- `UIElement_ItemList::ItemList_AddSpellShortcut @ 0x004E40A0` +- `UIElement_ItemList::ItemList_InsertSpellShortcut @ 0x004E41B0` +- `CM_Magic::Event_RemoveSpell @ 0x006A3220` + +DAT structure: + +```text +spell list 0x10000295 (280 x 224) +spell scrollbar 0x10000296 (16 x 224) +spell-row prototype property 0x1000000E -> 0x10000343 +shared UIItem catalog 0x21000037 +row prototype 0x10000343 (280 x 32) +row background 0x06001396 +selected overlay child 0x10000342 -> 0x06001397 +label child 0x10000344 (x=42, width=230) +icon child 0x1000033B (x=0, 32 x 32) +delete button 0x100002A5 +``` + +The row's authored background contains the narrow horizontal separator visible +between entries. It is not an invented procedural line. The selection surface +is a separate overlay, so selecting a spell does not replace its icon or label. + +```text +AddSpell(spellID): + if spell metadata is missing: return + if IsFilteredOut(spell): return + insertion = first existing row whose displayOrder > spell.displayOrder + list.InsertSpellShortcut(spellID, insertion) + +SetSelected(spellID): + for each spell shortcut row: + row.selectedOverlay = (row.spellID == spellID) + if selected: list.ScrollItemIntoView(row) + selectedSpellID = spellID + +ListenToElementMessage(message): + if message came from a spell shortcut row: + if message kind is click or double-click: SetSelected(row.spellID) + if message kind is AddSpellShortcut/drag notice: + CM_Magic.SendNotice_AddSpellShortcut(row.spellID) + if message is click on a school/level filter: + UpdateFilter(clickedFilter) + if message is click on 0x100002A5: + DeleteSpell() + +UpdateFilter(button): + mask = exact school/level bit assigned to button + if button is currently Highlight: filters |= mask + else: filters &= ~mask + if filters changed: + CM_Character.Event_SpellbookFilterEvent(filters) + rebuild list from authoritative learned spells + scroll list to first row + +IsFilteredOut(spell): + school bits: Creature=0x0001, Item=0x0002, Life=0x0004, + War=0x0008, Void=0x2000 + level bits: I=0x0010 through VIII=0x0800 + return school bit is disabled OR level bit is disabled + +DeleteSpell(): + if selectedSpellID == 0: return false + name = ClientMagicSystem.GetSpellName(selectedSpellID) + message = "Are you sure you want to remove {name} from your spellbook? " + "You will no longer be able to cast this spell unless you learn it again!" + dialog properties include selectedSpellID under 0x1000003F + DialogFactory.MakeCallbackDialogInCurrentUI(message, callback) + +DeleteSpellDialogCallback(dialogProperties): + if confirmation result property 0x92 is true: + spellID = property 0x1000003F + CM_Magic.Event_RemoveSpell(spellID) + +CM_Magic.Event_RemoveSpell(spellID): + send GameAction opcode 0x01A8 followed by spellID + +RecvNotice_SpellRemoved(spellID): + rebuild from the authoritative player description +``` + +The client does not optimistically erase the row when Yes is clicked. It waits +for the server's `MagicRemoveSpell` notice. Dragging a row similarly carries a +spell-shortcut identity into the open spellcasting favorite list; it never +removes the spell from the learned-spell set. + ## Active enchantment panels Retail: `gmEffectsUI::PostInit` `0x004B7560` and diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 13d6dfa1..eec3235c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2272,6 +2272,7 @@ public sealed class GameWindow : IDisposable (tab, spellId) => _liveSession?.SendRemoveSpellFavorite(spellId, tab), filters => _liveSession?.SendSpellbookFilter(filters), + spellId => _liveSession?.SendRemoveSpell(spellId), (componentId, amount) => _liveSession?.SendSetDesiredComponentLevel(componentId, amount), ClientTimerNow), diff --git a/src/AcDream.App/UI/Layout/SpellbookRowStyle.cs b/src/AcDream.App/UI/Layout/SpellbookRowStyle.cs new file mode 100644 index 00000000..7f81c516 --- /dev/null +++ b/src/AcDream.App/UI/Layout/SpellbookRowStyle.cs @@ -0,0 +1,72 @@ +using System.Numerics; +using DatReaderWriter; + +namespace AcDream.App.UI.Layout; + +/// +/// Resolved presentation of retail's spell-shortcut row prototype. The spellbook +/// list points at this prototype through ItemList attribute 0x1000000E; +/// retail clones it for every learned spell. +/// +public readonly record struct SpellbookRowStyle( + float Width, + float Height, + float IconLeft, + float IconTop, + float IconWidth, + float IconHeight, + float LabelLeft, + float LabelWidth, + uint FontDid, + Vector4 LabelColor, + uint BackgroundSprite, + uint SelectedSprite) +{ + public const uint CatalogLayoutId = 0x21000037u; + public const uint PrototypeId = 0x10000343u; + public const uint SelectedOverlayId = 0x10000342u; + public const uint LabelId = 0x10000344u; + public const uint IconId = 0x1000033Bu; + + /// + /// Resolve the complete row geometry and media from the installed retail DAT. + /// Returns null if the authored prototype is incomplete; callers then leave the + /// panel unbound rather than inventing replacement art. + /// + public static SpellbookRowStyle? TryLoad(DatCollection dats) + { + ArgumentNullException.ThrowIfNull(dats); + ElementInfo? prototype = LayoutImporter.ImportInfos(dats, CatalogLayoutId, PrototypeId); + if (prototype is null + || Find(prototype, SelectedOverlayId) is not { } selected + || Find(prototype, LabelId) is not { } label + || Find(prototype, IconId) is not { } icon + || !prototype.StateMedia.TryGetValue("", out var background) + || !selected.StateMedia.TryGetValue("", out var selection) + || prototype.Width <= 0f + || prototype.Height <= 0f) + return null; + + return new SpellbookRowStyle( + prototype.Width, + prototype.Height, + icon.X, + icon.Y, + icon.Width, + icon.Height, + label.X, + label.Width, + label.FontDid, + label.FontColor ?? Vector4.One, + background.File, + selection.File); + } + + private static ElementInfo? Find(ElementInfo root, uint id) + { + if (root.Id == id) return root; + foreach (ElementInfo child in root.Children) + if (Find(child, id) is { } found) return found; + return null; + } +} diff --git a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs index a6282ba2..a7ee7a8e 100644 --- a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs +++ b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs @@ -28,6 +28,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController public const uint SpellPageId = 0x100002ACu; public const uint ComponentPageId = 0x100002ADu; public const uint SpellListId = 0x10000295u; + public const uint SpellScrollbarId = 0x10000296u; + public const uint DeleteButtonId = 0x100002A5u; public const uint ComponentListId = 0x10000464u; public const uint ComponentScrollbarId = 0x10000465u; @@ -52,6 +54,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController private readonly Action _selectObject; private readonly Action _addFavorite; private readonly Action _sendFilter; + private readonly Action _removeSpell; + private readonly Action> _showConfirmation; private readonly Action _setDesiredComponent; private readonly Action _close; private readonly UiElement _spellPage; @@ -59,9 +63,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController private readonly UiElement _spellTab; private readonly UiElement _componentTab; private readonly UiButton _closeButton; + private readonly UiButton _deleteButton; private readonly UiItemList _spellList; private readonly UiItemList _componentList; private readonly List<(UiButton Button, uint Mask)> _filters = new(); + private readonly SpellbookRowStyle _rowStyle; + private readonly UiDatFont? _rowFont; private uint? _selectedSpell; private bool _spellsDirty; private bool _componentsDirty; @@ -80,6 +87,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController Action selectObject, Action addFavorite, Action sendFilter, + Action removeSpell, + Action> showConfirmation, Action setDesiredComponent, Action close, UiElement spellPage, @@ -87,8 +96,11 @@ public sealed class SpellbookWindowController : IRetainedPanelController UiElement spellTab, UiElement componentTab, UiButton closeButton, + UiButton deleteButton, UiItemList spellList, - UiItemList componentList) + UiItemList componentList, + SpellbookRowStyle rowStyle, + UiDatFont? rowFont) { _spellbook = spellbook; _objects = objects; @@ -100,6 +112,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController _selectObject = selectObject; _addFavorite = addFavorite; _sendFilter = sendFilter; + _removeSpell = removeSpell; + _showConfirmation = showConfirmation; _setDesiredComponent = setDesiredComponent; _close = close; _spellPage = spellPage; @@ -107,12 +121,16 @@ public sealed class SpellbookWindowController : IRetainedPanelController _spellTab = spellTab; _componentTab = componentTab; _closeButton = closeButton; + _deleteButton = deleteButton; _spellList = spellList; _componentList = componentList; + _rowStyle = rowStyle; + _rowFont = rowFont; SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells)); SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components)); closeButton.OnClick = close; + deleteButton.OnClick = RequestDeleteSelected; foreach ((uint id, uint mask) in FilterButtons) { if (layout.FindElement(id) is not UiButton button) continue; @@ -121,7 +139,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController _filters.Add((button, mask)); } - ConfigureSpellList(); + ConfigureSpellList(layout); ConfigureComponentList(layout); _spellbook.SpellbookChanged += OnSpellbookChanged; _spellbook.DesiredComponentsChanged += OnDesiredComponentsChanged; @@ -148,14 +166,19 @@ public sealed class SpellbookWindowController : IRetainedPanelController Action selectObject, Action addFavorite, Action sendFilter, + Action removeSpell, + Action> showConfirmation, Action setDesiredComponent, - Action close) + Action close, + SpellbookRowStyle rowStyle, + UiDatFont? rowFont) { if (layout.FindElement(SpellPageId) is not { } spellPage || layout.FindElement(ComponentPageId) is not { } componentPage || layout.FindElement(SpellTabId) is not { } spellTab || layout.FindElement(ComponentTabId) is not { } componentTab || layout.FindElement(CloseId) is not UiButton closeButton + || layout.FindElement(DeleteButtonId) is not UiButton deleteButton || layout.FindElement(SpellListId) is not UiItemList spellList) return null; @@ -179,9 +202,10 @@ public sealed class SpellbookWindowController : IRetainedPanelController layout, spellbook, objects, playerGuid, components, resolveSpellIcon, resolveComponentIcon, spellLevel, selectObject, - addFavorite, sendFilter, setDesiredComponent, close, + addFavorite, sendFilter, removeSpell, showConfirmation, + setDesiredComponent, close, spellPage, componentPage, spellTab, componentTab, closeButton, - spellList, componentList); + deleteButton, spellList, componentList, rowStyle, rowFont); } public void ShowPage(SpellbookWindowPage page) @@ -193,11 +217,13 @@ public sealed class SpellbookWindowController : IRetainedPanelController SetTabOpen(_componentTab, page == SpellbookWindowPage.Components); } - private void ConfigureSpellList() + private void ConfigureSpellList(ImportedLayout layout) { - _spellList.Columns = 6; - _spellList.CellWidth = 32f; - _spellList.CellHeight = 32f; + _spellList.Columns = 1; + _spellList.CellWidth = Math.Max(1f, _spellList.Width); + _spellList.CellHeight = _rowStyle.Height; + if (layout.FindElement(SpellScrollbarId) is UiScrollbar scrollbar) + scrollbar.Model = _spellList.Scroll; } private void ConfigureComponentList(ImportedLayout layout) @@ -211,9 +237,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController private void ToggleFilter(uint mask) { + // gmSpellbookUI::UpdateFilter @ 0x0048B5E0 persists only the changed + // filter bitfield, rebuilds from learned spells, and returns to row zero. uint filters = _spellbook.SpellbookFilters ^ mask; _spellbook.SetSpellbookFilters(filters); _sendFilter(filters); + _spellList.Scroll.SetScrollY(0); } private void RebuildAll() @@ -230,11 +259,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController foreach ((UiButton button, uint mask) in _filters) button.Selected = (effective & mask) != 0; + // gmSpellbookUI::GetSortedInsertionPlace @ 0x0048B440 inserts before + // the first GREATER display order. Stable OrderBy preserves ties. SpellMetadata[] spells = _spellbook.LearnedSpells .Select(id => _spellbook.TryGetMetadata(id, out SpellMetadata metadata) ? metadata : null) .Where(metadata => metadata is not null && IsVisible(metadata, effective)) .OrderBy(metadata => metadata!.SortKey) - .ThenBy(metadata => metadata!.Name, StringComparer.OrdinalIgnoreCase) .Cast() .ToArray(); @@ -249,7 +279,21 @@ public sealed class SpellbookWindowController : IRetainedPanelController EntryId = spellId, CatalogIconTexture = _resolveSpellIcon(spellId), Label = metadata.Name, + ShowLabel = true, + BackgroundSprite = _rowStyle.BackgroundSprite, + SelectedSprite = _rowStyle.SelectedSprite, + SelectionBehindContent = true, + LabelFont = _rowFont, + LabelColor = _rowStyle.LabelColor, + IconLeft = _rowStyle.IconLeft, + IconTop = _rowStyle.IconTop, + IconWidth = _rowStyle.IconWidth, + IconHeight = _rowStyle.IconHeight, + LabelLeft = _rowStyle.LabelLeft, + LabelWidth = _rowStyle.LabelWidth, SpriteResolve = _spellList.SpriteResolve, + CatalogDragPayload = new SpellbookShortcutDragPayload(spellId), + DragBegan = _ => SelectSpell(spellId), }; slot.Clicked = () => SelectSpell(spellId); // gmSpellbookUI double-click publishes AddSpellShortcut; the open @@ -399,8 +443,33 @@ public sealed class SpellbookWindowController : IRetainedPanelController private void SelectSpell(uint spellId) { + // gmSpellbookUI::SetSelected @ 0x0048B540 toggles the separate overlay + // for every row and asks the listbox to expose the matching row. _selectedSpell = spellId; SyncSpellSelection(); + for (int i = 0; i < _spellList.GetNumUIItems(); i++) + if (_spellList.GetItem(i) is UiCatalogSlot slot && slot.EntryId == spellId) + { + _spellList.ScrollItemIntoView(i); + break; + } + } + + private void RequestDeleteSelected() + { + // gmSpellbookUI::DeleteSpell @ 0x0048BD50 stores the spell id in the + // shared callback dialog; DeleteSpellDialogCallback @ 0x0048BC10 sends + // only after an affirmative result. The inbound remove notice owns UI state. + if (_selectedSpell is not uint spellId + || !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata)) + return; + + string message = $"Are you sure you want to remove {metadata.Name} from your spellbook? " + + "You will no longer be able to cast this spell unless you learn it again!"; + _showConfirmation(message, accepted => + { + if (accepted) _removeSpell(spellId); + }); } private void SyncSpellSelection() @@ -454,6 +523,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController SetClick(_spellTab, null); SetClick(_componentTab, null); _closeButton.OnClick = null; + _deleteButton.OnClick = null; foreach ((UiButton button, _) in _filters) button.OnClick = null; } diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs index 9b1b649e..c1e98500 100644 --- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs +++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs @@ -274,6 +274,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController UiItemList? list = _lists[tab]; if (list is null) continue; IReadOnlyList favorites = _spellbook.GetFavorites(tab); + int targetTab = tab; + list.CatalogDropped = (payload, x, _) => + { + if (payload is SpellbookShortcutDragPayload shortcut) + DropSpellbookShortcut(shortcut, targetTab, DropPosition(list, x)); + }; using (list.DeferLayout()) { list.Flush(); @@ -297,7 +303,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController Dropped = payload => { if (payload is SpellFavoriteDragPayload favorite) - DropFavorite(favorite, tab, position); + DropFavorite(favorite, targetTab, position); + else if (payload is SpellbookShortcutDragPayload shortcut) + DropSpellbookShortcut(shortcut, targetTab, position); }, }; slot.Clicked = () => SelectSpell(id); @@ -322,6 +330,27 @@ public sealed class SpellcastingUiController : IRetainedPanelController _selected[targetTab] = payload.SpellId; } + private void DropSpellbookShortcut( + SpellbookShortcutDragPayload payload, + int targetTab, + int targetPosition) + { + // gmSpellbookUI::ListenToElementMessage @ 0x0048BFD0 publishes + // CM_Magic::SendNotice_AddSpellShortcut; the open spellcasting menu + // chooses the favorite tab/position and persists it. + _addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId); + _spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId); + _selected[targetTab] = payload.SpellId; + } + + private static int DropPosition(UiItemList list, int localX) + { + int position = list.CellWidth <= 0f + ? list.GetNumUIItems() + : (int)MathF.Floor(MathF.Max(0f, localX) / list.CellWidth); + return Math.Clamp(position, 0, list.GetNumUIItems()); + } + private void OnSpellbookChanged() => _favoritesDirty = true; private void OnObjectChanged(ClientObject _) => _endowmentDirty = true; @@ -465,6 +494,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController public sealed record SpellFavoriteDragPayload(int SourceTab, int SourcePosition, uint SpellId); +/// +/// A learned-spell shortcut carried from the spellbook. Unlike a favorite drag, +/// lifting this payload never removes anything from its source collection. +/// +public sealed record SpellbookShortcutDragPayload(uint SpellId); + internal static class FavoriteListExtensions { public static int IndexOf(this IReadOnlyList values, uint value) diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 57cda9d6..96d4f42b 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -62,6 +62,7 @@ public sealed record MagicRuntimeBindings( Action AddFavorite, Action RemoveFavorite, Action SendSpellbookFilter, + Action RemoveSpell, Action SetDesiredComponent, Func ServerTime); @@ -707,6 +708,7 @@ public sealed class RetailUiRuntime : IDisposable private void MountSpellbook() { ImportedLayout? layout; + SpellbookRowStyle? rowStyle; lock (_bindings.Assets.DatLock) { layout = LayoutImporter.Import( @@ -716,13 +718,19 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveSprite, _bindings.Assets.DefaultFont, _bindings.Assets.ResolveFont); + rowStyle = SpellbookRowStyle.TryLoad(_bindings.Assets.Dats); } - if (layout is null) + if (layout is null || rowStyle is null) { - Console.WriteLine("[M3] spellbook: LayoutDesc 0x21000034 not found."); + Console.WriteLine("[M3] spellbook: layout or authored UIItem row prototype not found."); return; } + UiDatFont? rowFont = rowStyle.Value.FontDid == 0u + ? _bindings.Assets.DefaultFont + : _bindings.Assets.ResolveFont(rowStyle.Value.FontDid) + ?? _bindings.Assets.DefaultFont; + SpellbookWindowController? controller = Layout.SpellbookWindowController.Bind( layout, _bindings.Magic.Spellbook, @@ -735,8 +743,12 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Magic.SelectObject, spellId => SpellcastingUiController?.AddFavorite(spellId), _bindings.Magic.SendSpellbookFilter, + _bindings.Magic.RemoveSpell, + (message, completed) => ShowConfirmation(message, completed), _bindings.Magic.SetDesiredComponent, - () => CloseWindow(WindowNames.Spellbook)); + () => CloseWindow(WindowNames.Spellbook), + rowStyle.Value, + rowFont); if (controller is null) { Console.WriteLine("[M3] spellbook: required controls missing in LayoutDesc 0x21000034."); diff --git a/src/AcDream.App/UI/UiCatalogSlot.cs b/src/AcDream.App/UI/UiCatalogSlot.cs index 5a704adb..eaa27bc0 100644 --- a/src/AcDream.App/UI/UiCatalogSlot.cs +++ b/src/AcDream.App/UI/UiCatalogSlot.cs @@ -1,12 +1,13 @@ using System; using System.Numerics; +using AcDream.App.Rendering; namespace AcDream.App.UI; /// /// Icon-list cell for non-weenie catalog entries such as spells and components. /// It reuses the retail UIItemList geometry without pretending a spell id is an -/// object GUID and without participating in item drag/drop. +/// object GUID; catalog drags stay distinct from physical item drag/drop. /// public sealed class UiCatalogSlot : UiItemSlot { @@ -17,6 +18,16 @@ public sealed class UiCatalogSlot : UiItemSlot public string Label { get; set; } = string.Empty; public string? Detail { get; set; } public bool ShowLabel { get; init; } + public uint BackgroundSprite { get; init; } + public UiDatFont? LabelFont { get; init; } + public Vector4 LabelColor { get; init; } = new(0.92f, 0.88f, 0.70f, 1f); + public float IconLeft { get; init; } + public float IconTop { get; init; } + public float IconWidth { get; init; } + public float IconHeight { get; init; } + public float LabelLeft { get; init; } + public float LabelWidth { get; init; } + public bool SelectionBehindContent { get; init; } public new Action? Clicked { get; set; } public new Action? DoubleClicked { get; set; } @@ -67,31 +78,78 @@ public sealed class UiCatalogSlot : UiItemSlot protected override void OnDraw(UiRenderContext ctx) { - float iconSize = ShowLabel ? MathF.Min(32f, Height) : Width; + if (BackgroundSprite != 0u && SpriteResolve is not null) + { + var (texture, _, _) = SpriteResolve(BackgroundSprite); + if (texture != 0u) + ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + + if (SelectionBehindContent) + DrawSelection(ctx); + + float iconWidth = IconWidth > 0f ? IconWidth : ShowLabel ? MathF.Min(32f, Height) : Width; + float iconHeight = IconHeight > 0f ? IconHeight : ShowLabel ? MathF.Min(32f, Height) : Height; if (CatalogIconTexture != 0) - ctx.DrawSprite(CatalogIconTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One); + ctx.DrawSprite(CatalogIconTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One); else if (SpriteResolve is not null && EmptySprite != 0) { var (texture, _, _) = SpriteResolve(EmptySprite); if (texture != 0) - ctx.DrawSprite(texture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One); + ctx.DrawSprite(texture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One); } if (CatalogOverlayTexture != 0) - ctx.DrawSprite(CatalogOverlayTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One); + ctx.DrawSprite(CatalogOverlayTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One); - if (Selected && SpriteResolve is not null && SelectedSprite != 0) - { - var (texture, _, _) = SpriteResolve(SelectedSprite); - if (texture != 0) - ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } + if (!SelectionBehindContent) + DrawSelection(ctx); if (ShowLabel) { - ctx.DrawString(Label, iconSize + 4f, 3f, new Vector4(0.92f, 0.88f, 0.70f, 1f)); + float labelLeft = LabelLeft > 0f ? LabelLeft : IconLeft + iconWidth + 4f; + float labelWidth = LabelWidth > 0f ? LabelWidth : MathF.Max(0f, Width - labelLeft); + if (LabelFont is not null) + { + string text = FitText(Label, labelWidth, LabelFont.MeasureWidth); + float y = MathF.Max(0f, (Height - LabelFont.LineHeight) * 0.5f); + ctx.DrawStringDat(LabelFont, text, labelLeft, y, LabelColor); + } + else + { + BitmapFont? font = ctx.DefaultFont; + string text = font is null ? Label : FitText(Label, labelWidth, font.MeasureWidth); + float y = font is null ? 3f : MathF.Max(0f, (Height - font.LineHeight) * 0.5f); + ctx.DrawString(text, labelLeft, y, LabelColor, font); + } if (!string.IsNullOrWhiteSpace(Detail)) - ctx.DrawString(Detail!, MathF.Max(iconSize + 4f, Width - 48f), 3f, Vector4.One); + ctx.DrawString(Detail!, MathF.Max(labelLeft, Width - 48f), 3f, Vector4.One); } } + + private void DrawSelection(UiRenderContext ctx) + { + if (!Selected || SpriteResolve is null || SelectedSprite == 0u) return; + var (texture, _, _) = SpriteResolve(SelectedSprite); + if (texture != 0u) + ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + + private static string FitText(string text, float width, Func measure) + { + if (width <= 0f || string.IsNullOrEmpty(text) || measure(text) <= width) + return text; + + const string ellipsis = "..."; + float ellipsisWidth = measure(ellipsis); + if (ellipsisWidth >= width) return string.Empty; + int low = 0, high = text.Length; + while (low < high) + { + int mid = (low + high + 1) / 2; + if (measure(text[..mid]) + ellipsisWidth <= width) low = mid; + else high = mid - 1; + } + return text[..low] + ellipsis; + } } diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index a8d18d06..aaeddfa1 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -39,6 +39,13 @@ public sealed class UiItemList : UiElement public Func? SpriteResolve { get; set; } + /// + /// Optional non-weenie catalog drop target. Used by retail spell shortcuts; + /// physical-item drops continue through . + /// Coordinates are local to this list. + /// + public Action? CatalogDropped { 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 @@ -204,6 +211,16 @@ public sealed class UiItemList : UiElement public override bool OnEvent(in UiEvent e) { + if (CatalogDropped is not null && e.Payload is not null) + { + if (e.Type is UiEventType.DragEnter or UiEventType.DragOver) + return true; + if (e.Type == UiEventType.DropReleased) + { + CatalogDropped(e.Payload, e.Data1, e.Data2); + return true; + } + } if (e.Type == UiEventType.Scroll && CellWidth > 0f) { // Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0. @@ -213,6 +230,24 @@ public sealed class UiItemList : UiElement return base.OnEvent(e); } + /// + /// Port of the listbox expose-item behavior called by + /// gmSpellbookUI::SetSelected @ 0x0048B540. + /// + public void ScrollItemIntoView(int index) + { + if (CellWidth <= 0f || index < 0 || index >= _cells.Count) return; + int columns = Math.Max(1, Columns); + int row = Flow == UiItemListFlow.ColumnMajor + ? index % Math.Max(1, RowCount(_cells.Count, columns)) + : index / columns; + float top = row * CellHeight; + float bottom = top + CellHeight; + if (top < Scroll.ScrollY) Scroll.SetScrollY((int)MathF.Floor(top)); + else if (bottom > Scroll.ScrollY + Height) + Scroll.SetScrollY((int)MathF.Ceiling(bottom - Height)); + } + protected override void OnDraw(UiRenderContext ctx) { // The factory sets Width/Height AFTER construction, so re-layout each frame: diff --git a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs index 9f3dda9d..022817af 100644 --- a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs +++ b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs @@ -36,6 +36,7 @@ public static class ClientCommandRequests public const uint AddSpellFavoriteOpcode = 0x01E3u; public const uint RemoveSpellFavoriteOpcode = 0x01E4u; public const uint SpellbookFilterOpcode = 0x0286u; + public const uint RemoveSpellOpcode = 0x01A8u; public const uint LegacyFriendsOpcode = 0xF7CDu; // Named-retail anchors: @@ -205,6 +206,10 @@ public static class ClientCommandRequests public static byte[] BuildSpellbookFilter(uint sequence, uint filters) => BuildUInt32(sequence, SpellbookFilterOpcode, filters); + // CM_Magic::Event_RemoveSpell @ 0x006A3220. + public static byte[] BuildRemoveSpell(uint sequence, uint spellId) => + BuildUInt32(sequence, RemoveSpellOpcode, spellId); + private static byte[] BuildParameterless(uint sequence, uint opcode) { byte[] body = new byte[12]; diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index b9c7ea01..14e1bc0d 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1480,6 +1480,12 @@ public sealed class WorldSession : IDisposable SendGameAction(ClientCommandRequests.BuildSpellbookFilter(seq, filters)); } + public void SendRemoveSpell(uint spellId) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildRemoveSpell(seq, spellId)); + } + public void SendCastUntargetedSpell(uint spellId) { uint seq = NextGameActionSequence(); diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellbookRowStyleTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellbookRowStyleTests.cs new file mode 100644 index 00000000..ff2c31f0 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/SpellbookRowStyleTests.cs @@ -0,0 +1,32 @@ +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class SpellbookRowStyleTests +{ + [Fact] + public void InstalledDat_ResolvesPinnedRetailSpellShortcutPrototype() + { + string datDir = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + if (!Directory.Exists(datDir)) return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + SpellbookRowStyle style = Assert.IsType( + SpellbookRowStyle.TryLoad(dats)); + + Assert.Equal(280f, style.Width); + Assert.Equal(32f, style.Height); + Assert.Equal(0f, style.IconLeft); + Assert.Equal(32f, style.IconWidth); + Assert.Equal(42f, style.LabelLeft); + Assert.Equal(230f, style.LabelWidth); + Assert.Equal(0x40000001u, style.FontDid); + Assert.Equal(System.Numerics.Vector4.One, style.LabelColor); + Assert.Equal(0x06001396u, style.BackgroundSprite); + Assert.Equal(0x06001397u, style.SelectedSprite); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs index f11bc20d..c7415378 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs @@ -3,30 +3,33 @@ using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Spells; +using System.Numerics; namespace AcDream.App.Tests.UI.Layout; public sealed class SpellbookWindowControllerTests { + private static readonly SpellbookRowStyle RowStyle = new( + Width: 280f, + Height: 32f, + IconLeft: 0f, + IconTop: 0f, + IconWidth: 32f, + IconHeight: 32f, + LabelLeft: 42f, + LabelWidth: 230f, + FontDid: 0x40000001u, + LabelColor: Vector4.One, + BackgroundSprite: 0x06001396u, + SelectedSprite: 0x06001397u); + [Fact] public void RetailFixture_BindsTextTabs_AndPropagatesOpenClosedState() { ImportedLayout layout = FixtureLoader.LoadSpellbook(); var closed = new List(); - using SpellbookWindowController? controller = SpellbookWindowController.Bind( - layout, - new Spellbook(), - new ClientObjectTable(), - () => 1u, - new Dictionary(), - spellId => spellId, - iconId => iconId, - _ => 1, - _ => { }, - _ => { }, - _ => { }, - (_, _) => { }, - () => closed.Add(1u)); + using SpellbookWindowController? controller = Bind( + layout, new Spellbook(), close: () => closed.Add(1u)); Assert.NotNull(controller); UiText spellTab = Assert.IsType(layout.FindElement(SpellbookWindowController.SpellTabId)); @@ -53,4 +56,176 @@ public sealed class SpellbookWindowControllerTests Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom(child).ActiveRetailStateId)); Assert.Empty(closed); } + + [Fact] + public void LearnedSpells_AreAuthoredRows_InDisplayOrder_WithSelectionAndDragPayload() + { + ImportedLayout layout = FixtureLoader.LoadSpellbook(); + Spellbook book = CreateSpellbook(); + book.OnSpellLearned(101u); + book.OnSpellLearned(102u); + book.OnSpellLearned(103u); + var added = new List(); + using SpellbookWindowController controller = Bind( + layout, book, addFavorite: added.Add)!; + + UiItemList list = Assert.IsType( + layout.FindElement(SpellbookWindowController.SpellListId)); + UiScrollbar scrollbar = Assert.IsType( + layout.FindElement(SpellbookWindowController.SpellScrollbarId)); + + Assert.Equal(1, list.Columns); + Assert.Equal(280f, list.CellWidth); + Assert.Equal(32f, list.CellHeight); + Assert.Same(list.Scroll, scrollbar.Model); + Assert.Equal(3, list.GetNumUIItems()); + + UiCatalogSlot first = Assert.IsType(list.GetItem(0)); + UiCatalogSlot second = Assert.IsType(list.GetItem(1)); + UiCatalogSlot third = Assert.IsType(list.GetItem(2)); + Assert.Equal([102u, 101u, 103u], [first.EntryId, second.EntryId, third.EntryId]); + Assert.Equal("Creature One", first.Label); + Assert.True(first.ShowLabel); + Assert.Equal(RowStyle.BackgroundSprite, first.BackgroundSprite); + Assert.Equal(RowStyle.SelectedSprite, first.SelectedSprite); + Assert.True(first.SelectionBehindContent); + Assert.Equal(RowStyle.LabelLeft, first.LabelLeft); + + second.OnEvent(new UiEvent(0, second, UiEventType.Click)); + Assert.True(second.Selected); + Assert.False(first.Selected); + + first.OnEvent(new UiEvent( + 0, first, UiEventType.DragBegin, + Payload: new SpellbookShortcutDragPayload(first.EntryId))); + Assert.True(first.Selected); + Assert.IsType(first.GetDragPayload()); + + first.OnEvent(new UiEvent(0, first, UiEventType.DoubleClick)); + Assert.Equal([102u], added); + } + + [Fact] + public void SchoolAndLevelFilters_RebuildFromAuthoritativeBitfield() + { + ImportedLayout layout = FixtureLoader.LoadSpellbook(); + Spellbook book = CreateSpellbook(); + book.OnSpellLearned(101u); // War I + book.OnSpellLearned(102u); // Creature I + book.OnSpellLearned(103u); // War VIII + var sent = new List(); + using SpellbookWindowController controller = Bind( + layout, book, sendFilter: sent.Add)!; + UiItemList list = Assert.IsType( + layout.FindElement(SpellbookWindowController.SpellListId)); + UiButton war = Assert.IsType(layout.FindElement(0x1000029Bu)); + UiButton levelOne = Assert.IsType(layout.FindElement(0x1000029Cu)); + + Assert.True(war.Selected); + Assert.Equal(3, list.GetNumUIItems()); + + war.OnEvent(new UiEvent(0, war, UiEventType.Click)); + controller.Tick(); + Assert.Equal([0x3FF7u], sent); + Assert.Single(VisibleSpellIds(list)); + Assert.Equal(102u, VisibleSpellIds(list)[0]); + + war.OnEvent(new UiEvent(0, war, UiEventType.Click)); + levelOne.OnEvent(new UiEvent(0, levelOne, UiEventType.Click)); + controller.Tick(); + Assert.Equal([103u], VisibleSpellIds(list)); + Assert.False(levelOne.Selected); + } + + [Fact] + public void Delete_UsesSharedConfirmation_AndWaitsForServerRemovalNotice() + { + ImportedLayout layout = FixtureLoader.LoadSpellbook(); + Spellbook book = CreateSpellbook(); + book.OnSpellLearned(101u); + var removed = new List(); + string? prompt = null; + Action? complete = null; + using SpellbookWindowController controller = Bind( + layout, + book, + removeSpell: removed.Add, + showConfirmation: (message, callback) => + { + prompt = message; + complete = callback; + })!; + UiItemList list = Assert.IsType( + layout.FindElement(SpellbookWindowController.SpellListId)); + UiButton delete = Assert.IsType( + layout.FindElement(SpellbookWindowController.DeleteButtonId)); + + delete.OnEvent(new UiEvent(0, delete, UiEventType.Click)); + Assert.Null(prompt); + + UiCatalogSlot row = Assert.IsType(list.GetItem(0)); + row.OnEvent(new UiEvent(0, row, UiEventType.Click)); + delete.OnEvent(new UiEvent(0, delete, UiEventType.Click)); + + Assert.Equal( + "Are you sure you want to remove War One from your spellbook? " + + "You will no longer be able to cast this spell unless you learn it again!", + prompt); + Assert.Empty(removed); + complete!(false); + Assert.Empty(removed); + Assert.True(book.Knows(101u)); + + delete.OnEvent(new UiEvent(0, delete, UiEventType.Click)); + complete!(true); + Assert.Equal([101u], removed); + Assert.True(book.Knows(101u)); + Assert.Single(VisibleSpellIds(list)); + + book.OnSpellForgotten(101u); + controller.Tick(); + Assert.False(book.Knows(101u)); + Assert.Empty(VisibleSpellIds(list)); + } + + private static SpellbookWindowController? Bind( + ImportedLayout layout, + Spellbook spellbook, + Action? addFavorite = null, + Action? sendFilter = null, + Action? removeSpell = null, + Action>? showConfirmation = null, + Action? close = null) + => SpellbookWindowController.Bind( + layout, + spellbook, + new ClientObjectTable(), + () => 1u, + new Dictionary(), + spellId => spellId + 1000u, + iconId => iconId, + spellId => spellId == 103u ? 8 : 1, + _ => { }, + addFavorite ?? (_ => { }), + sendFilter ?? (_ => { }), + removeSpell ?? (_ => { }), + showConfirmation ?? ((_, _) => { }), + (_, _) => { }, + close ?? (() => { }), + RowStyle, + rowFont: null); + + private static Spellbook CreateSpellbook() + { + const string csv = "Spell ID,Name,School,IconId [Hex],SortKey\n" + + "101,War One,War Magic,0x06000001,2\n" + + "102,Creature One,Creature Enchantment,0x06000002,1\n" + + "103,War Eight,War Magic,0x06000003,3\n"; + return new Spellbook(SpellTable.LoadFromReader(new StringReader(csv))); + } + + private static uint[] VisibleSpellIds(UiItemList list) + => Enumerable.Range(0, list.GetNumUIItems()) + .Select(index => Assert.IsType(list.GetItem(index)).EntryId) + .ToArray(); } diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs index 43207884..9d55c234 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs @@ -78,6 +78,35 @@ public sealed class SpellcastingUiControllerTests Assert.Equal([42u], spellbook.GetFavorites(0)); } + [Fact] + public void SpellbookShortcutDrop_AddsToOpenTabWithoutRemovingLearnedSpell() + { + ImportedLayout layout = LayoutImporter.Build( + FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); + var spellbook = new Spellbook(); + spellbook.OnSpellLearned(42u, 1f); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" }); + var added = new List<(int Tab, int Position, uint Spell)>(); + using SpellcastingUiController controller = Bind( + layout, spellbook, objects, _ => { }, + (tab, position, spell) => added.Add((tab, position, spell)))!; + UiElement group = Assert.IsAssignableFrom(layout.FindElement(0x100000AAu)); + UiItemList list = Descendants(group).OfType().First(); + + bool handled = list.OnEvent(new UiEvent( + 0, + list, + UiEventType.DropReleased, + Data1: 5, + Payload: new SpellbookShortcutDragPayload(42u))); + + Assert.True(handled); + Assert.Equal([(0, 0, 42u)], added); + Assert.Equal([42u], spellbook.GetFavorites(0)); + Assert.True(spellbook.Knows(42u)); + } + [Fact] public void UnrelatedObjectUpdate_DoesNotRecreateFavoriteSlots() { @@ -137,7 +166,8 @@ public sealed class SpellcastingUiControllerTests ImportedLayout layout, Spellbook spellbook, ClientObjectTable objects, - Action useItem) + Action useItem, + Action? addFavorite = null) { var casting = new SpellCastingController( spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { }); @@ -147,7 +177,7 @@ public sealed class SpellcastingUiControllerTests item => item.ObjectId, useItem, new SelectionState(), - (_, _, _) => { }, + addFavorite ?? ((_, _, _) => { }), (_, _) => { }); } diff --git a/tests/AcDream.App.Tests/UI/UiItemListTests.cs b/tests/AcDream.App.Tests/UI/UiItemListTests.cs index b03e9ed6..3ef8276f 100644 --- a/tests/AcDream.App.Tests/UI/UiItemListTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemListTests.cs @@ -43,4 +43,43 @@ public class UiItemListTests list.CellEmptySprite = 0u; Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // unchanged default } + + [Fact] + public void CatalogDrop_routes_payload_and_local_coordinates() + { + var list = new UiItemList(); + object payload = new(); + (object Payload, int X, int Y)? received = null; + list.CatalogDropped = (value, x, y) => received = (value, x, y); + + bool handled = list.OnEvent(new UiEvent( + 0, list, UiEventType.DropReleased, Data1: 17, Data2: 9, Payload: payload)); + + Assert.True(handled); + Assert.NotNull(received); + Assert.Same(payload, received.Value.Payload); + Assert.Equal((17, 9), (received.Value.X, received.Value.Y)); + } + + [Fact] + public void ScrollItemIntoView_moves_only_when_row_is_outside_viewport() + { + var list = new UiItemList + { + Width = 100f, + Height = 64f, + Columns = 1, + CellWidth = 100f, + CellHeight = 32f, + }; + list.Flush(); + for (int i = 0; i < 5; i++) list.AddItem(new UiItemSlot()); + + list.ScrollItemIntoView(3); + Assert.Equal(64, list.Scroll.ScrollY); + list.ScrollItemIntoView(2); + Assert.Equal(64, list.Scroll.ScrollY); + list.ScrollItemIntoView(0); + Assert.Equal(0, list.Scroll.ScrollY); + } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs index c8fef2c2..4804b64f 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs @@ -160,16 +160,20 @@ public sealed class ClientCommandRequestsTests } [Fact] - public void RemoveFavoriteAndFilter_MatchRetailPayloads() + public void RemoveFavoriteFilterAndLearnedSpell_MatchRetailPayloads() { byte[] remove = ClientCommandRequests.BuildRemoveSpellFavorite(2u, 42u, 6); byte[] filter = ClientCommandRequests.BuildSpellbookFilter(3u, 0xA5u); + byte[] removeLearned = ClientCommandRequests.BuildRemoveSpell(4u, 77u); Assert.Equal(ClientCommandRequests.RemoveSpellFavoriteOpcode, Read(remove, 8)); Assert.Equal(42u, Read(remove, 12)); Assert.Equal(6u, Read(remove, 16)); Assert.Equal(ClientCommandRequests.SpellbookFilterOpcode, Read(filter, 8)); Assert.Equal(0xA5u, Read(filter, 12)); + Assert.Equal(16, removeLearned.Length); + Assert.Equal(ClientCommandRequests.RemoveSpellOpcode, Read(removeLearned, 8)); + Assert.Equal(77u, Read(removeLearned, 12)); } [Fact]