From b7dc91a0531ab0f5ae8c4cbb558d9462c8981721 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 3 Jul 2026 09:18:43 +0200 Subject: [PATCH] feat(ui): D.2b item interaction + retail cursors + live character sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02 UI architecture review mandated before commit: - ItemInteractionController: single owner of double-click use/equip/ container-open, targeted-use mode (health kits), drag-out drop; toolbar shortcut drags don't drop the real item. ItemEquipRules for multi-slot (coat) coverage via equip masks. - Cursor phase: CursorFeedbackController (semantic priority chain: drag > resize > window-move > target-mode > text) + RetailCursorCatalog (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState 0x00564630) resolved through the portal EnumIDMap chain by RetailCursorResolver; RetailCursorManager applies dat cursor art to the OS cursor. Register row AP-72 covers the OS standard-cursor fallback. - Character window goes live: CharacterSheetProvider owns sheet assembly, XP-curve/raise-cost math and the raise flow — extracted out of GameWindow per Code Structure Rule 1 instead of committing the ~430-line feature body there. Optimistic XP/credit debits go through eventful store APIs (new ClientObjectTable.UpdateInt64Property + LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw property-dictionary writes; register row AP-73 covers the still-missing raise ledger (#163). - RetailWindowFrame: the shared nine-slice window mount recipe; the character window uses it, remaining windows migrate via #164. - Status-bar buttons toggle inventory/character windows; retail row-major backpack ordering; WorldSession.SendUseWithTarget + raise/train sends. GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the sheet/raise logic is unit-tested in CharacterSheetProviderTests instead of trapped in the god object. Build green; full suite 3,286 tests pass. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 4 +- src/AcDream.App/Rendering/GameWindow.cs | 157 +++- .../Rendering/RetailCursorManager.cs | 142 ++++ .../Rendering/RetailCursorResolver.cs | 76 ++ src/AcDream.App/RuntimeOptions.cs | 11 +- src/AcDream.App/Studio/SampleData.cs | 11 +- .../UI/CursorFeedbackController.cs | 255 +++++++ src/AcDream.App/UI/ItemEquipRules.cs | 38 + .../UI/ItemInteractionController.cs | 383 ++++++++++ .../UI/Layout/CharacterIdentityText.cs | 68 ++ src/AcDream.App/UI/Layout/CharacterSheet.cs | 21 +- .../UI/Layout/CharacterSheetProvider.cs | 478 ++++++++++++ .../UI/Layout/CharacterStatController.cs | 702 ++++++++++++++---- .../UI/Layout/ChatWindowController.cs | 6 + src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 5 + src/AcDream.App/UI/Layout/ElementReader.cs | 13 +- .../UI/Layout/InventoryController.cs | 53 +- src/AcDream.App/UI/Layout/LayoutImporter.cs | 18 +- .../UI/Layout/PaperdollController.cs | 18 +- .../UI/Layout/RetailWindowFrame.cs | 108 +++ .../UI/Layout/ToolbarController.cs | 50 +- src/AcDream.App/UI/Layout/UiDatElement.cs | 2 + .../UI/Layout/WindowChromeController.cs | 54 ++ src/AcDream.App/UI/RetailCursorCatalog.cs | 30 + .../UI/Testing/RetailUiAutomationProbe.cs | 306 ++++++++ .../Testing/RetailUiAutomationScriptRunner.cs | 348 +++++++++ src/AcDream.App/UI/UiButton.cs | 5 + src/AcDream.App/UI/UiElement.cs | 61 ++ src/AcDream.App/UI/UiHost.cs | 3 + src/AcDream.App/UI/UiItemList.cs | 40 +- src/AcDream.App/UI/UiItemSlot.cs | 6 + src/AcDream.App/UI/UiMeter.cs | 2 + src/AcDream.App/UI/UiPanel.cs | 6 +- src/AcDream.App/UI/UiRoot.cs | 62 +- src/AcDream.App/UI/UiScrollablePanel.cs | 109 +++ src/AcDream.App/UI/UiText.cs | 3 + src/AcDream.Core.Net/GameEventWiring.cs | 72 +- src/AcDream.Core.Net/Messages/CreateObject.cs | 6 +- src/AcDream.Core.Net/Messages/GameEvents.cs | 7 +- src/AcDream.Core.Net/ObjectTableWiring.cs | 4 +- src/AcDream.Core.Net/WorldSession.cs | 42 +- src/AcDream.Core/Items/ClientObject.cs | 69 +- src/AcDream.Core/Items/ClientObjectTable.cs | 90 ++- src/AcDream.Core/Player/LocalPlayerState.cs | 159 ++++ .../RuntimeOptionsRetailUiTests.cs | 16 + .../AcDream.App.Tests/RuntimeOptionsTests.cs | 3 + .../UI/CursorFeedbackControllerTests.cs | 155 ++++ .../UI/ItemInteractionControllerTests.cs | 303 ++++++++ .../UI/Layout/CharacterLayoutImportProbe.cs | 109 +++ .../UI/Layout/CharacterSheetProviderTests.cs | 192 +++++ .../UI/Layout/CharacterStatControllerTests.cs | 434 ++++++++++- .../UI/Layout/ChatWindowControllerTests.cs | 13 + .../UI/Layout/DatWidgetFactoryTests.cs | 12 + .../UI/Layout/ElementReaderTests.cs | 16 + .../UI/Layout/InventoryControllerTests.cs | 92 ++- .../UI/Layout/InventoryFrameImportProbe.cs | 31 + .../UI/Layout/LayoutImporterTests.cs | 16 + .../UI/Layout/PaperdollControllerTests.cs | 45 +- .../UI/Layout/ToolbarControllerTests.cs | 108 +++ .../UI/RetailCursorCatalogTests.cs | 61 ++ .../UI/RetailUiAutomationProbeTests.cs | 179 +++++ .../UI/RetailUiInteractionFlowTests.cs | 291 ++++++++ .../UI/UiItemListGridTests.cs | 27 + tests/AcDream.App.Tests/UI/UiItemSlotTests.cs | 12 + .../AcDream.App.Tests/UI/UiRootInputTests.cs | 54 +- .../UI/UiScrollablePanelTests.cs | 39 + .../GameEventWiringTests.cs | 129 +++- .../Messages/CreateObjectTests.cs | 19 +- .../Messages/GameEventDispatcherTests.cs | 16 + .../ObjectTableWiringTests.cs | 4 + .../WorldSessionCombatTests.cs | 67 ++ .../Items/ClientObjectTableTests.cs | 155 +++- .../Items/ItemUseabilityTests.cs | 30 + .../Player/LocalPlayerStateTests.cs | 176 +++++ 74 files changed, 6669 insertions(+), 238 deletions(-) create mode 100644 src/AcDream.App/Rendering/RetailCursorManager.cs create mode 100644 src/AcDream.App/Rendering/RetailCursorResolver.cs create mode 100644 src/AcDream.App/UI/CursorFeedbackController.cs create mode 100644 src/AcDream.App/UI/ItemEquipRules.cs create mode 100644 src/AcDream.App/UI/ItemInteractionController.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterIdentityText.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterSheetProvider.cs create mode 100644 src/AcDream.App/UI/Layout/RetailWindowFrame.cs create mode 100644 src/AcDream.App/UI/Layout/WindowChromeController.cs create mode 100644 src/AcDream.App/UI/RetailCursorCatalog.cs create mode 100644 src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs create mode 100644 src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs create mode 100644 src/AcDream.App/UI/UiScrollablePanel.cs create mode 100644 tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs create mode 100644 tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs create mode 100644 tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs create mode 100644 tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs create mode 100644 tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs create mode 100644 tests/AcDream.App.Tests/UI/UiScrollablePanelTests.cs create mode 100644 tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 00f7d5b4..2d88228a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -98,7 +98,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 68 rows +## 3. Documented approximation (AP) — 70 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -171,6 +171,8 @@ accepted-divergence entries (#96, #49, #50). | AP-69 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render | | AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) | | AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 | +| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 | +| AP-73 | **Character raises apply optimistically with no pending/rollback ledger** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits unassigned XP / skill credits locally (via eventful store APIs). Retail's local-predict behavior for raises is unverified; item moves in acdream reconcile via RecordPending/Confirm/Rollback but raises do not (**#163**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`HandleRaiseRequest`/`ApplyLocalRaise`) | The next server property echo (PlayerDescription / private stat updates) is authoritative and overwrites every optimistic value; ACE confirms affordable raises, and the affordability gate runs client-side first (`Cost <= 0` refuses). | A server-rejected raise (XP race, validation) leaves ranks/XP/credits wrong on the sheet until the next full property refresh — retire by porting the ledger (#163) or cdb-tracing retail's raise flow. | unverified — candidate cdb trace: retail raise-button send path | --- ## 4. Temporary stopgap (TS) — 32 rows diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 731d815c..e2b2dea8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -659,8 +659,12 @@ public sealed class GameWindow : IDisposable private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm; // Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1. private AcDream.App.UI.UiHost? _uiHost; + private AcDream.App.UI.Testing.RetailUiAutomationScriptRunner? _uiAutomationRunner; + private AcDream.App.UI.CursorFeedbackController? _cursorFeedbackController; + private RetailCursorManager? _retailCursorManager; // Phase D.5.1 — toolbar controller (kept for lifetime clarity; mirrors _chatWindowController pattern). private AcDream.App.UI.Layout.ToolbarController? _toolbarController; + private AcDream.App.UI.ItemInteractionController? _itemInteractionController; // Phase D.5.3a — selected-object strip controller (name, overlay state, health meter). private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController; // Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter). @@ -764,6 +768,9 @@ public sealed class GameWindow : IDisposable // and out keeps the right skills. private int _lastSeenRunSkill = -1; private int _lastSeenJumpSkill = -1; + // Phase D.2b-C — live character-sheet assembly + raise flow (extracted + // feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1. + private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider; // K.1b: this field is RESERVED — written when entering / leaving player // mode and previously fed mouse-X into MovementInput.MouseDeltaX. Now // never consumed by MovementInput (mouse never drives character yaw — @@ -1829,6 +1836,28 @@ public sealed class GameWindow : IDisposable { _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); + _itemInteractionController = new AcDream.App.UI.ItemInteractionController( + Objects, + playerGuid: () => _playerServerGuid, + sendUse: g => _liveSession?.SendUse(g), + sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target), + sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), + sendDrop: item => _liveSession?.SendDropItem(item), + toast: text => _debugVm?.AddToast(text)); + _cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(_itemInteractionController); + _retailCursorManager = new RetailCursorManager(_dats!, _datLock); + _characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider( + Objects, LocalPlayer, + playerGuid: () => _playerServerGuid, + activeToonName: () => _activeToonKey, + fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter, + canSendRaise: () => _liveSession is not null + && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld, + sendRaiseAttribute: (statId, cost) => _liveSession?.SendRaiseAttribute(statId, cost), + sendRaiseVital: (statId, cost) => _liveSession?.SendRaiseVital(statId, cost), + sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost), + sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits)); + _uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside; // Feed Silk input to the UiRoot tree so windows drag / close / select. // UiRoot consumes UI events; the game InputDispatcher (subscribed to the @@ -2079,6 +2108,7 @@ public sealed class GameWindow : IDisposable peaceDigits: toolbarPeaceDigits, warDigits: toolbarWarDigits, emptyDigits: toolbarEmptyDigits, + itemInteraction: _itemInteractionController, sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g), sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i)); @@ -2176,6 +2206,47 @@ public sealed class GameWindow : IDisposable } else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found."); + // Phase D.2b-C: live Character window (Attributes / Skills / Titles) from + // LayoutDesc 0x2100002E. Starts hidden and is toggled by the status-bar + // character button; stats use the same retail-bound sheet shape as the studio + // until the live character stat feed is wired. + AcDream.App.UI.Layout.ImportedLayout? characterLayout; + lock (_datLock) + characterLayout = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats!, 0x2100002Eu, ResolveChrome, vitalsDatFont, ResolveDatFont); + if (characterLayout is not null) + { + AcDream.App.UI.Layout.CharacterStatController.Bind( + characterLayout, + data: _characterSheetProvider!.BuildSheet, + datFont: vitalsDatFont, + rowDatFont: ResolveDatFont(0x40000001u) ?? vitalsDatFont, + spriteResolve: ResolveChrome, + onRaiseRequest: _characterSheetProvider.HandleRaiseRequest, + onClose: () => CloseRetailWindow(AcDream.App.UI.WindowNames.Character)); + + // Retail-authored content is 380px tall but the skill list wants to + // grow: bottom-edge-only resize up to 760px, X locked to the dat width. + AcDream.App.UI.Layout.RetailWindowFrame.Mount( + _uiHost.Root, characterLayout.Root, ResolveChrome, + new AcDream.App.UI.Layout.RetailWindowFrame.Options + { + Left = 540f, + Top = 18f, + MaxHeight = 760f, + ResizeX = false, + ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom, + Visible = false, + ContentAnchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top + | AcDream.App.UI.AnchorEdges.Bottom, + ContentClickThrough = false, + WindowName = AcDream.App.UI.WindowNames.Character, + }); + + Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E)."); + } + else Console.WriteLine("[D.2b-C] character: LayoutDesc 0x2100002E not found."); + // Drain plugin-registered markup panels (buffered before the GL // window opened) into the same UiRoot tree. A faulty plugin markup // file is isolated — logged + skipped, never crashes the client. @@ -2294,13 +2365,16 @@ public sealed class GameWindow : IDisposable AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa ? (int?)sa.Current : null, datFont: vitalsDatFont, + ownerName: _characterSheetProvider!.CharacterName, contentsEmptySprite: contentsEmpty, sideBagEmptySprite: sideBagEmpty, mainPackEmptySprite: mainPackEmpty, sendUse: g => _liveSession?.SendUse(g), sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g), sendPutItemInContainer: (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement)); + _liveSession?.SendPutItemInContainer(item, container, placement), + itemInteraction: _itemInteractionController, + onClose: () => CloseRetailWindow(AcDream.App.UI.WindowNames.Inventory)); // Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear + // wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid). @@ -2312,7 +2386,8 @@ public sealed class GameWindow : IDisposable // Empty equip slots show a visible frame (same square as the inventory grid) so every // slot position is seen + usable; the live 3D character (the doll) is Slice 2. emptySlotSprite: contentsEmpty, - datFont: vitalsDatFont); // Slice 2: caption the "Slots" toggle button + datFont: vitalsDatFont, + itemInteraction: _itemInteractionController); // Slice 2: caption the "Slots" toggle button // Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory // frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only @@ -2324,6 +2399,25 @@ public sealed class GameWindow : IDisposable Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); } else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found."); + + _toolbarController?.BindWindowToggles( + toggleInventory: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory), + toggleCharacter: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Character)); + SyncToolbarWindowButtons(); + + if (_options.UiProbeEnabled) + { + void ProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message); + var probe = new AcDream.App.UI.Testing.RetailUiAutomationProbe( + _uiHost.Root, + Objects, + ProbeLog); + _uiAutomationRunner = new AcDream.App.UI.Testing.RetailUiAutomationScriptRunner( + probe, + _options.UiProbeScript, + _options.UiProbeDump, + ProbeLog); + } } // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is @@ -2689,6 +2783,13 @@ public sealed class GameWindow : IDisposable // and jumps undershot retail by ~30 % at typical // attribute levels. var skillTable = _dats?.Get(0x0E000004u); + if (_characterSheetProvider is not null && _dats is not null) + { + _characterSheetProvider.SkillTable = skillTable; + _characterSheetProvider.ExperienceTable = + AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable( + _dats, Console.WriteLine); + } AcDream.Core.Net.GameEventWiring.WireAll( _liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer, @@ -9498,6 +9599,8 @@ public sealed class GameWindow : IDisposable if (_options.RetailUi && _uiHost is not null) { _uiHost.Tick(deltaSeconds); + _uiAutomationRunner?.Tick(deltaSeconds); + UpdateRetailCursorFeedback(); _uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); } @@ -11654,6 +11757,42 @@ public sealed class GameWindow : IDisposable // first EnterWorld. private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore; private string _activeToonKey = "default"; + + private bool ToggleRetailWindow(string name) + { + bool visible = _uiHost?.ToggleWindow(name) ?? false; + SyncToolbarWindowButtons(); + return visible; + } + + private void CloseRetailWindow(string name) + { + _uiHost?.HideWindow(name); + SyncToolbarWindowButtons(); + } + + private void SyncToolbarWindowButtons() + { + if (_toolbarController is null || _uiHost is null) return; + _toolbarController.SetInventoryOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Inventory)); + _toolbarController.SetCharacterOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Character)); + } + + private void UpdateRetailCursorFeedback() + { + if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null) + return; + + var feedback = _cursorFeedbackController.Update(_uiHost.Root); + _retailCursorManager.Apply(_input.Mice, feedback); + } + + private void OnUiDragReleasedOutside(object payload, int x, int y) + { + if (payload is AcDream.App.UI.ItemDragPayload itemPayload) + _itemInteractionController?.DropToWorld(itemPayload); + } + // L.0 follow-up: persisted-settings cache populated by // LoadAndApplyPersistedSettings (runs unconditionally in OnLoad, // not gated on DevToolsEnabled). The Settings PANEL construction @@ -12046,7 +12185,7 @@ public sealed class GameWindow : IDisposable // Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it // does not fire while the chat input holds focus. Null _uiHost = // retail UI off (ACDREAM_RETAIL_UI unset) → no-op. - _uiHost?.ToggleWindow(AcDream.App.UI.WindowNames.Inventory); + ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory); break; case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel: @@ -12153,7 +12292,9 @@ public sealed class GameWindow : IDisposable break; case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: - if (_cameraController?.IsFlyMode == true) + if (_itemInteractionController?.IsTargetModeActive == true) + _itemInteractionController.CancelTargetMode(); + else if (_cameraController?.IsFlyMode == true) _cameraController.ToggleFly(); // exit fly, release cursor else if (_playerMode) { @@ -12291,6 +12432,12 @@ public sealed class GameWindow : IDisposable if (picked is uint guid) { + if (_itemInteractionController?.IsTargetModeActive == true) + { + _itemInteractionController.AcquireTarget(guid); + return; + } + SelectedGuid = guid; string label = DescribeLiveEntity(guid); Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); @@ -12324,6 +12471,8 @@ public sealed class GameWindow : IDisposable } else { + if (_itemInteractionController?.IsTargetModeActive == true) + return; _debugVm?.AddToast("Nothing to select"); } } diff --git a/src/AcDream.App/Rendering/RetailCursorManager.cs b/src/AcDream.App/Rendering/RetailCursorManager.cs new file mode 100644 index 00000000..099aa4a0 --- /dev/null +++ b/src/AcDream.App/Rendering/RetailCursorManager.cs @@ -0,0 +1,142 @@ +using AcDream.App.UI; +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using Silk.NET.Core; +using Silk.NET.Input; + +namespace AcDream.App.Rendering; + +/// Applies retail cursor feedback to Silk using dat MediaDescCursor art when available. +public sealed class RetailCursorManager +{ + private readonly DatCollection _dats; + private readonly object _datLock; + private readonly RetailCursorResolver _globalCursors; + private readonly Dictionary _imagesBySurface = new(); + private readonly HashSet _missingSurfaces = new(); + private CursorFeedbackKind _lastKind = (CursorFeedbackKind)(-1); + private UiCursorMedia _lastCursor; + + public RetailCursorManager(DatCollection dats, object datLock) + { + _dats = dats; + _datLock = datLock; + _globalCursors = new RetailCursorResolver(dats, datLock); + } + + public void Apply(IEnumerable mice, CursorFeedback feedback) + { + if (feedback.Cursor.IsValid && TryGetImage(feedback.Cursor.File, out var image)) + { + ApplyCustom(mice, feedback.Cursor, image); + _lastKind = feedback.Kind; + _lastCursor = feedback.Cursor; + return; + } + + if (_globalCursors.TryResolve(feedback.Kind, out var globalCursor) + && TryGetImage(globalCursor.File, out var globalImage)) + { + ApplyCustom(mice, globalCursor, globalImage); + _lastKind = feedback.Kind; + _lastCursor = globalCursor; + return; + } + + ApplyStandard(mice, StandardCursorFor(feedback.Kind)); + _lastKind = feedback.Kind; + _lastCursor = default; + } + + private void ApplyCustom(IEnumerable mice, UiCursorMedia cursorMedia, RawImage image) + { + if (_lastCursor.Equals(cursorMedia)) + return; + + foreach (var mouse in mice) + { + var cursor = mouse.Cursor; + cursor.Image = image; + cursor.HotspotX = cursorMedia.HotspotX; + cursor.HotspotY = cursorMedia.HotspotY; + if (cursor.Type != CursorType.Custom) + cursor.Type = CursorType.Custom; + } + } + + private void ApplyStandard(IEnumerable mice, StandardCursor desired) + { + if (_lastCursor.Equals(default(UiCursorMedia)) && _lastKind != (CursorFeedbackKind)(-1) + && StandardCursorFor(_lastKind) == desired) + return; + + foreach (var mouse in mice) + { + var cursor = mouse.Cursor; + var standard = desired; + if (!cursor.IsSupported(standard)) + standard = StandardCursor.Arrow; + if (!cursor.IsSupported(standard)) + continue; + + if (cursor.Type != CursorType.Standard) + cursor.Type = CursorType.Standard; + if (cursor.StandardCursor != standard) + cursor.StandardCursor = standard; + } + } + + private bool TryGetImage(uint renderSurfaceId, out RawImage image) + { + if (_imagesBySurface.TryGetValue(renderSurfaceId, out image)) + return true; + if (_missingSurfaces.Contains(renderSurfaceId)) + return false; + + DecodedTexture? decoded = DecodeCursorSurface(renderSurfaceId); + if (decoded is null || decoded.Width <= 0 || decoded.Height <= 0 || decoded.Rgba8.Length == 0) + { + _missingSurfaces.Add(renderSurfaceId); + image = default; + return false; + } + + image = new RawImage(decoded.Width, decoded.Height, decoded.Rgba8); + _imagesBySurface[renderSurfaceId] = image; + return true; + } + + private DecodedTexture? DecodeCursorSurface(uint renderSurfaceId) + { + lock (_datLock) + { + if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) + && !_dats.HighRes.TryGet(renderSurfaceId, out rs)) + return null; + + Palette? palette = rs.DefaultPaletteId != 0 + ? _dats.Get(rs.DefaultPaletteId) + : null; + return SurfaceDecoder.DecodeRenderSurface(rs, palette); + } + } + + private static StandardCursor StandardCursorFor(CursorFeedbackKind kind) + => kind switch + { + CursorFeedbackKind.Text => StandardCursor.IBeam, + CursorFeedbackKind.WindowMove => StandardCursor.ResizeAll, + CursorFeedbackKind.ResizeHorizontal => StandardCursor.HResize, + CursorFeedbackKind.ResizeVertical => StandardCursor.VResize, + CursorFeedbackKind.ResizeDiagonalNwse => StandardCursor.NwseResize, + CursorFeedbackKind.ResizeDiagonalNesw => StandardCursor.NeswResize, + CursorFeedbackKind.Drag => StandardCursor.Hand, + CursorFeedbackKind.DragAccept => StandardCursor.ResizeAll, + CursorFeedbackKind.DragReject => StandardCursor.NotAllowed, + CursorFeedbackKind.TargetPending => StandardCursor.Crosshair, + CursorFeedbackKind.TargetValid => StandardCursor.ResizeAll, + CursorFeedbackKind.TargetInvalid => StandardCursor.NotAllowed, + _ => StandardCursor.Arrow, + }; +} diff --git a/src/AcDream.App/Rendering/RetailCursorResolver.cs b/src/AcDream.App/Rendering/RetailCursorResolver.cs new file mode 100644 index 00000000..4cced48b --- /dev/null +++ b/src/AcDream.App/Rendering/RetailCursorResolver.cs @@ -0,0 +1,76 @@ +using AcDream.App.UI; +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// Resolves retail global cursor enum IDs through the portal EnumIDMap chain. +internal sealed class RetailCursorResolver +{ + private readonly DatCollection _dats; + private readonly object _datLock; + private readonly Dictionary _didByEnum = new(); + private readonly HashSet _missingEnums = new(); + + public RetailCursorResolver(DatCollection dats, object datLock) + { + _dats = dats; + _datLock = datLock; + } + + public bool TryResolve(CursorFeedbackKind kind, out UiCursorMedia cursor) + { + cursor = default; + if (!RetailCursorCatalog.TryGetGlobalCursor(kind, out var spec)) + return false; + + return TryResolve(spec, out cursor); + } + + internal bool TryResolve(RetailCursorSpec spec, out UiCursorMedia cursor) + { + cursor = default; + if (!spec.IsValid) + return false; + + if (_didByEnum.TryGetValue(spec.EnumId, out uint cachedDid)) + { + cursor = new UiCursorMedia(cachedDid, spec.HotspotX, spec.HotspotY); + return true; + } + if (_missingEnums.Contains(spec.EnumId)) + return false; + + uint did = ResolveDidByEnum(spec.EnumId); + if (did == 0) + { + _missingEnums.Add(spec.EnumId); + return false; + } + + cursor = new UiCursorMedia(did, spec.HotspotX, spec.HotspotY); + _didByEnum[spec.EnumId] = did; + return true; + } + + private uint ResolveDidByEnum(uint cursorEnum) + { + lock (_datLock) + { + uint masterDid = (uint)_dats.Portal.Header.MasterMapId; + if (masterDid == 0) + return 0; + + if (!_dats.Portal.TryGet(masterDid, out var master) || master is null) + return 0; + + if (!master.ClientEnumToID.TryGetValue(RetailCursorCatalog.CursorEnumTable, out uint cursorMapDid)) + return 0; + + if (!_dats.Portal.TryGet(cursorMapDid, out var cursorMap) || cursorMap is null) + return 0; + + return cursorMap.ClientEnumToID.TryGetValue(cursorEnum, out uint did) ? did : 0; + } + } +} diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 9be7601d..9f04385e 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -41,7 +41,9 @@ public sealed record RuntimeOptions( bool DumpLiveSpawns, int? LegacyStreamRadius, bool RetailUi, - string? AcDir) + string? AcDir, + bool UiProbeDump, + string? UiProbeScript) { /// /// Build options from the process environment. Used by @@ -85,13 +87,18 @@ public sealed record RuntimeOptions( // top of the quality preset's radii. Null when unset or invalid. LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), - AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); + AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")), + UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")), + UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT"))); } /// True iff live-mode credentials are present and valid for connecting. public bool HasLiveCredentials => LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass); + /// True when the opt-in retail UI automation probe should be constructed. + public bool UiProbeEnabled => UiProbeDump || !string.IsNullOrEmpty(UiProbeScript); + private static bool IsExactlyOne(string? s) => string.Equals(s, "1", StringComparison.Ordinal); diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs index 1a53a6ea..396c322c 100644 --- a/src/AcDream.App/Studio/SampleData.cs +++ b/src/AcDream.App/Studio/SampleData.cs @@ -122,12 +122,14 @@ public static class SampleData /// synthetic character. Values are plausible retail-scale numbers so the /// report renders with well-proportioned text in all sections. /// - public static CharacterSheet SampleCharacter() => new() + public static CharacterSheet SampleCharacter() => SampleCharacter(null); + + public static CharacterSheet SampleCharacter(string? name) => new() { - Name = "Studio Player", + Name = string.IsNullOrWhiteSpace(name) ? "Studio Player" : name, Level = 126, - Race = "Aluvian", - Heritage = "Aluvian Heritage", + Gender = "Female", + Heritage = "Aluvian", Title = "the Adventurer", BirthDate = "January 5, 2001", PlayTime = "2 years, 114 days, 4 hours", @@ -166,6 +168,7 @@ public static class SampleData // Focus@10 → 110 matches the authoritative retail screenshot (spec §4). // Formula bracket at value=10: ExperienceToAttributeLevel(11) − ExperienceToAttributeLevel(10). AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L }, + AttributeRaise10Costs = new long[] { 0L, 950L, 1_000L, 0L, 1_100L, 1_050L, 900L, 880L, 1_120L }, // Real SkillTable icon IDs and train/specialize costs from client_portal.dat // SkillTable 0x0E000004. gmSkillUI groups/sorts these at bind time. diff --git a/src/AcDream.App/UI/CursorFeedbackController.cs b/src/AcDream.App/UI/CursorFeedbackController.cs new file mode 100644 index 00000000..71ae6231 --- /dev/null +++ b/src/AcDream.App/UI/CursorFeedbackController.cs @@ -0,0 +1,255 @@ +namespace AcDream.App.UI; + +/// +/// Semantic cursor state for the retail-look UI. Retail stores actual cursor art +/// as MediaDescCursor on UI states; this controller owns the behavior decision so +/// the visual backend can apply dat cursors without +/// changing item/window interaction code. +/// +public enum CursorFeedbackKind +{ + Default, + Text, + WindowMove, + ResizeHorizontal, + ResizeVertical, + ResizeDiagonalNwse, + ResizeDiagonalNesw, + Drag, + DragAccept, + DragReject, + TargetPending, + TargetValid, + TargetInvalid, +} + +public readonly record struct CursorFeedback(CursorFeedbackKind Kind, UiCursorMedia Cursor = default) +{ + public static CursorFeedback Default { get; } = new(CursorFeedbackKind.Default); +} + +public readonly record struct CursorFeedbackSnapshot( + object? DragPayload = null, + UiItemSlot.DragAcceptState DragAccept = UiItemSlot.DragAcceptState.None, + ResizeEdges ActiveResizeEdges = ResizeEdges.None, + ResizeEdges HoverResizeEdges = ResizeEdges.None, + bool WindowMoveActive = false, + bool HoverWindowMove = false, + bool HoverUi = false, + bool HoverTextEdit = false, + uint HoverTargetGuid = 0); + +public sealed class CursorFeedbackController +{ + private readonly ItemInteractionController? _itemInteraction; + + public CursorFeedbackController(ItemInteractionController? itemInteraction = null) + { + _itemInteraction = itemInteraction; + } + + public CursorFeedback Current { get; private set; } = CursorFeedback.Default; + + public CursorFeedback Update(UiRoot root) + { + ArgumentNullException.ThrowIfNull(root); + + UiElement? hover = root.Pick(root.MouseX, root.MouseY); + var snapshot = new CursorFeedbackSnapshot( + DragPayload: root.DragPayload, + DragAccept: FindHoveredItemSlot(hover)?.DragAcceptVisual ?? UiItemSlot.DragAcceptState.None, + ActiveResizeEdges: root.ActiveResizeEdges, + HoverResizeEdges: root.HoverResizeEdges, + WindowMoveActive: root.IsWindowMoveActive, + HoverWindowMove: root.HoverWindowMove, + HoverUi: hover is not null, + HoverTextEdit: hover?.IsEditControl == true, + HoverTargetGuid: ResolveUseTargetGuid(hover)); + + var kind = ResolveKind(snapshot); + Current = new CursorFeedback(kind, ResolveCursor(root.Captured, hover, kind)); + return Current; + } + + public CursorFeedback Update(CursorFeedbackSnapshot snapshot) + { + Current = Resolve(snapshot); + return Current; + } + + public CursorFeedback Resolve(CursorFeedbackSnapshot snapshot) + => new(ResolveKind(snapshot)); + + private CursorFeedbackKind ResolveKind(CursorFeedbackSnapshot snapshot) + { + if (snapshot.DragPayload is not null) + { + return snapshot.DragAccept switch + { + UiItemSlot.DragAcceptState.Accept => CursorFeedbackKind.DragAccept, + UiItemSlot.DragAcceptState.Reject => CursorFeedbackKind.DragReject, + _ => CursorFeedbackKind.Drag, + }; + } + + ResizeEdges resizeEdges = snapshot.ActiveResizeEdges != ResizeEdges.None + ? snapshot.ActiveResizeEdges + : snapshot.HoverResizeEdges; + if (resizeEdges != ResizeEdges.None) + return KindForResize(resizeEdges); + + if (snapshot.WindowMoveActive) + return CursorFeedbackKind.WindowMove; + + if (_itemInteraction?.IsTargetModeActive == true) + { + if (snapshot.HoverTargetGuid != 0) + { + return _itemInteraction.IsCurrentTargetCompatible(snapshot.HoverTargetGuid) + ? CursorFeedbackKind.TargetValid + : CursorFeedbackKind.TargetInvalid; + } + + return snapshot.HoverUi + ? CursorFeedbackKind.TargetInvalid + : CursorFeedbackKind.TargetPending; + } + + if (snapshot.HoverWindowMove) + return CursorFeedbackKind.WindowMove; + + if (snapshot.HoverTextEdit) + return CursorFeedbackKind.Text; + + return CursorFeedbackKind.Default; + } + + private static CursorFeedbackKind KindForResize(ResizeEdges edges) + { + bool horizontal = (edges & (ResizeEdges.Left | ResizeEdges.Right)) != 0; + bool vertical = (edges & (ResizeEdges.Top | ResizeEdges.Bottom)) != 0; + + if (horizontal && vertical) + { + bool nwse = + ((edges & ResizeEdges.Left) != 0 && (edges & ResizeEdges.Top) != 0) + || ((edges & ResizeEdges.Right) != 0 && (edges & ResizeEdges.Bottom) != 0); + return nwse ? CursorFeedbackKind.ResizeDiagonalNwse : CursorFeedbackKind.ResizeDiagonalNesw; + } + + if (horizontal) return CursorFeedbackKind.ResizeHorizontal; + if (vertical) return CursorFeedbackKind.ResizeVertical; + return CursorFeedbackKind.Default; + } + + private static UiCursorMedia ResolveCursor(UiElement? captured, UiElement? hover, CursorFeedbackKind kind) + { + foreach (var stateName in CursorStateNamesForKind(kind)) + { + var semantic = FindCursorForState(hover, stateName); + if (semantic.IsValid) + return semantic; + } + + var capturedCursor = FindActiveCursor(captured); + if (capturedCursor.IsValid) + return capturedCursor; + + var hoverCursor = FindActiveCursor(hover); + if (hoverCursor.IsValid) + return hoverCursor; + + return default; + } + + private static IReadOnlyList CursorStateNamesForKind(CursorFeedbackKind kind) + => kind switch + { + CursorFeedbackKind.DragAccept => new[] + { + "Drag_rollover_accept", + "ItemSlot_DragOver_Accept", + "ItemSlot_DragOver_DropIn", + }, + CursorFeedbackKind.TargetValid => new[] + { + "Drag_rollover_accept", + "ItemSlot_DragOver_Accept", + "ItemSlot_DragOver_DropIn", + "Csm_highlight", + }, + CursorFeedbackKind.DragReject or CursorFeedbackKind.TargetInvalid => new[] + { + "Drag_rollover_reject", + "ItemSlot_DragOver_Reject", + "Csm_ghosted", + }, + CursorFeedbackKind.Drag => new[] + { + "ItemSlot_DragOver_Normal", + "Drag_rollover_accept", + }, + CursorFeedbackKind.TargetPending => new[] + { + "DDDMode", + "Csm_normal", + }, + CursorFeedbackKind.WindowMove => new[] + { + "Csm_highlight", + "Csm_normal", + }, + _ => Array.Empty(), + }; + + private static UiCursorMedia FindCursorForState(UiElement? element, string stateName) + { + while (element is not null) + { + var cursor = element.CursorForState(stateName, allowFallback: false); + if (cursor.IsValid) + return cursor; + element = element.Parent; + } + return default; + } + + private static UiCursorMedia FindActiveCursor(UiElement? element) + { + while (element is not null) + { + var cursor = element.ActiveCursor(); + if (cursor.IsValid) + return cursor; + element = element.Parent; + } + return default; + } + + private static UiItemSlot? FindHoveredItemSlot(UiElement? element) + { + while (element is not null) + { + if (element is UiItemSlot slot) + return slot; + element = element.Parent; + } + return null; + } + + private static uint ResolveUseTargetGuid(UiElement? element) + { + while (element is not null) + { + uint provided = element.UseTargetGuidProvider?.Invoke() ?? 0u; + if (provided != 0) + return provided; + + if (element is UiItemSlot { ItemId: not 0 } slot) + return slot.ItemId; + + element = element.Parent; + } + return 0u; + } +} diff --git a/src/AcDream.App/UI/ItemEquipRules.cs b/src/AcDream.App/UI/ItemEquipRules.cs new file mode 100644 index 00000000..13293046 --- /dev/null +++ b/src/AcDream.App/UI/ItemEquipRules.cs @@ -0,0 +1,38 @@ +using AcDream.Core.Items; + +namespace AcDream.App.UI; + +internal static class ItemEquipRules +{ + // Retail CPlayerSystem::AutoWear / gmPaperDollUI inventory mask: 0x08007FFF. + public const EquipMask AutoWearMask = + EquipMask.HeadWear + | EquipMask.ChestWear + | EquipMask.AbdomenWear + | EquipMask.UpperArmWear + | EquipMask.LowerArmWear + | EquipMask.HandWear + | EquipMask.UpperLegWear + | EquipMask.LowerLegWear + | EquipMask.FootWear + | EquipMask.ChestArmor + | EquipMask.AbdomenArmor + | EquipMask.UpperArmArmor + | EquipMask.LowerArmArmor + | EquipMask.UpperLegArmor + | EquipMask.LowerLegArmor + | EquipMask.Cloak; + + public static bool IsAutoWearItem(ClientObject item) + => (item.ValidLocations & AutoWearMask) != EquipMask.None; + + public static EquipMask ResolvePaperdollDropWieldMask(ClientObject item, EquipMask targetMask) + { + if ((item.ValidLocations & targetMask) == EquipMask.None) + return EquipMask.None; + + return IsAutoWearItem(item) + ? item.ValidLocations + : item.ValidLocations & targetMask; + } +} diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs new file mode 100644 index 00000000..bfdd3505 --- /dev/null +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -0,0 +1,383 @@ +using System; +using AcDream.Core.Items; + +namespace AcDream.App.UI; + +/// +/// Shared retail item interaction orchestrator. UI widgets route item clicks, +/// target acquisition, and drag-out drops here instead of duplicating +/// ItemHolder::UseObject fragments in each panel. +/// +public sealed class ItemInteractionController +{ + private static readonly EquipMask[] AutoEquipOrder = + { + EquipMask.HeadWear, + EquipMask.ChestWear, + EquipMask.AbdomenWear, + EquipMask.UpperArmWear, + EquipMask.LowerArmWear, + EquipMask.HandWear, + EquipMask.UpperLegWear, + EquipMask.LowerLegWear, + EquipMask.FootWear, + EquipMask.ChestArmor, + EquipMask.AbdomenArmor, + EquipMask.UpperArmArmor, + EquipMask.LowerArmArmor, + EquipMask.UpperLegArmor, + EquipMask.LowerLegArmor, + EquipMask.NeckWear, + EquipMask.WristWearLeft, + EquipMask.WristWearRight, + EquipMask.FingerWearLeft, + EquipMask.FingerWearRight, + EquipMask.Shield, + EquipMask.MissileAmmo, + EquipMask.MeleeWeapon, + EquipMask.MissileWeapon, + EquipMask.Held, + EquipMask.TwoHanded, + EquipMask.TrinketOne, + EquipMask.Cloak, + EquipMask.SigilOne, + EquipMask.SigilTwo, + EquipMask.SigilThree, + }; + + private const long RetailUseThrottleMs = 200; + + private readonly ClientObjectTable _objects; + private readonly Func _playerGuid; + private readonly Func _nowMs; + private readonly Action? _sendUse; + private readonly Action? _sendUseWithTarget; + private readonly Action? _sendWield; + private readonly Action? _sendDrop; + private readonly Action? _toast; + + private long _lastUseMs = long.MinValue / 2; + + public ItemInteractionController( + ClientObjectTable objects, + Func playerGuid, + Action? sendUse, + Action? sendUseWithTarget, + Action? sendWield, + Action? sendDrop, + Func? nowMs = null, + Action? toast = null) + { + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _sendUse = sendUse; + _sendUseWithTarget = sendUseWithTarget; + _sendWield = sendWield; + _sendDrop = sendDrop; + _nowMs = nowMs ?? (() => Environment.TickCount64); + _toast = toast; + } + + public event Action? StateChanged; + + public uint PlayerGuid => _playerGuid(); + + public uint PendingSourceItem { get; private set; } + + public bool IsTargetModeActive => PendingSourceItem != 0; + + public bool IsPendingSource(uint itemGuid) + => itemGuid != 0 && itemGuid == PendingSourceItem; + + public bool IsCurrentTargetCompatible(uint targetGuid) + { + if (!IsTargetModeActive || targetGuid == 0) return false; + + var source = _objects.Get(PendingSourceItem); + return source?.Useability is { } useability + && TargetCompatible(source, targetGuid, useability); + } + + public bool ActivateItem(uint itemGuid) + { + if (itemGuid == 0) return false; + + if (IsTargetModeActive) + return AcquireTarget(itemGuid); + + var item = _objects.Get(itemGuid); + if (item is null) return false; + + if (!ConsumeUseThrottle()) + return true; + + if (item.Useability is { } targetedUse + && ItemUseability.IsTargeted(targetedUse) + && SourceCompatible(item, targetedUse)) + { + EnterTargetMode(itemGuid); + return true; + } + + if (IsContainer(item)) + { + _sendUse?.Invoke(itemGuid); + return true; + } + + if (TryAutoWield(item)) + return true; + + if (item.Useability is { } directUse + && ItemUseability.IsDirectUseable(directUse) + && SourceCompatible(item, directUse)) + { + _sendUse?.Invoke(itemGuid); + return true; + } + + return false; + } + + public bool AcquireTarget(uint targetGuid) + { + if (!IsTargetModeActive || targetGuid == 0) return false; + + uint sourceGuid = PendingSourceItem; + ClearTargetMode(); + + var source = _objects.Get(sourceGuid); + if (source?.Useability is not { } useability) + return false; + + if (!TargetCompatible(source, targetGuid, useability)) + return false; + + _sendUseWithTarget?.Invoke(sourceGuid, targetGuid); + return true; + } + + public bool AcquireSelfTarget() + => IsTargetModeActive && AcquireTarget(_playerGuid()); + + public void CancelTargetMode() + { + if (!IsTargetModeActive) return; + ClearTargetMode(); + } + + public bool DropToWorld(ItemDragPayload payload) + { + ArgumentNullException.ThrowIfNull(payload); + + if (payload.SourceKind == ItemDragSource.ShortcutBar) + return false; + if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is null) + return false; + + _objects.MoveItemOptimistic(payload.ObjId, newContainerId: 0u, newSlot: -1); + _sendDrop?.Invoke(payload.ObjId); + return true; + } + + private void EnterTargetMode(uint sourceGuid) + { + PendingSourceItem = sourceGuid; + StateChanged?.Invoke(); + var name = _objects.Get(sourceGuid)?.Name; + if (!string.IsNullOrWhiteSpace(name)) + _toast?.Invoke($"Choose a target for the {name}"); + } + + private void ClearTargetMode() + { + PendingSourceItem = 0; + StateChanged?.Invoke(); + } + + private bool ConsumeUseThrottle() + { + long now = _nowMs(); + if (now - _lastUseMs < RetailUseThrottleMs) + return false; + _lastUseMs = now; + return true; + } + + private bool TryAutoWield(ClientObject item) + { + if (item.ValidLocations == EquipMask.None + || item.CurrentlyEquippedLocation != EquipMask.None) + return false; + + EquipMask mask = BestAvailableEquipMask(item); + if (mask == EquipMask.None) + { + _toast?.Invoke("That slot is already in use"); + return false; + } + + if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask)) + return false; + + _sendWield?.Invoke(item.ObjectId, (uint)mask); + return true; + } + + private EquipMask BestAvailableEquipMask(ClientObject item) + { + if (ItemEquipRules.IsAutoWearItem(item)) + return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None; + + return FirstAvailableEquipMask(item); + } + + private bool AutoWearIsLegal(ClientObject item) + { + uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId); + if ((item.Priority & priorityMask) == 0) + return true; + + EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) & item.ValidLocations; + return GetEquippedObjectAtLocation(occupiedLocations, item.Priority, item.ObjectId) is null; + } + + private EquipMask FirstAvailableEquipMask(ClientObject item) + { + foreach (var mask in AutoEquipOrder) + { + if ((item.ValidLocations & mask) == EquipMask.None) + continue; + if (!EquipMaskOccupied(mask, item.ObjectId)) + return mask; + } + return EquipMask.None; + } + + private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid) + { + foreach (var o in _objects.Objects) + { + if (o.ObjectId == exceptGuid) + continue; + if ((o.CurrentlyEquippedLocation & mask) == EquipMask.None) + continue; + if (IsEquippedByPlayer(o)) + return true; + } + return false; + } + + private uint EquippedAutoWearPriorityMask(uint exceptGuid) + { + uint mask = 0; + foreach (var o in _objects.Objects) + { + if (o.ObjectId == exceptGuid) + continue; + if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None) + continue; + if (IsEquippedByPlayer(o)) + mask |= o.Priority; + } + return mask; + } + + private EquipMask EquippedAutoWearLocationMask(uint exceptGuid) + { + EquipMask mask = EquipMask.None; + foreach (var o in _objects.Objects) + { + if (o.ObjectId == exceptGuid) + continue; + if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None) + continue; + if (IsEquippedByPlayer(o)) + mask |= o.CurrentlyEquippedLocation; + } + return mask; + } + + private ClientObject? GetEquippedObjectAtLocation(EquipMask locationMask, uint priority, uint exceptGuid) + { + if (locationMask == EquipMask.None) + return null; + + foreach (var o in _objects.Objects) + { + if (o.ObjectId == exceptGuid) + continue; + if (!IsEquippedByPlayer(o)) + continue; + if ((o.CurrentlyEquippedLocation & locationMask) == EquipMask.None) + continue; + if ((o.Priority & priority) != 0 || priority == 0) + return o; + } + return null; + } + + private static bool IsContainer(ClientObject item) + => item.ContainerTypeHint != 0 + || item.Type.HasFlag(ItemType.Container) + || item.ItemsCapacity > 0; + + private bool SourceCompatible(ClientObject source, uint useability) + { + uint flags = ItemUseability.SourceFlags(useability); + if ((flags & ItemUseability.Remote) != 0) return true; + if ((flags & ItemUseability.Viewed) != 0) return true; + if ((flags & ItemUseability.Self) != 0 && source.ObjectId == _playerGuid()) return true; + if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(source)) return true; + if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(source)) return true; + return false; + } + + private bool TargetCompatible(ClientObject source, uint targetGuid, uint useability) + { + uint flags = ItemUseability.TargetFlags(useability); + uint player = _playerGuid(); + + if (targetGuid == source.ObjectId && (flags & ItemUseability.ObjSelf) != 0) + return true; + + if (targetGuid == player) + return (flags & ItemUseability.Self) != 0; + + var target = _objects.Get(targetGuid); + if (target is null) + return (flags & ItemUseability.Remote) != 0; + + if (source.TargetType is { } targetType && targetType != 0 + && ((uint)target.Type & targetType) == 0) + return false; + + if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(target)) return true; + if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(target)) return true; + if ((flags & ItemUseability.Viewed) != 0 && IsCarriedByPlayer(target)) return true; + if ((flags & ItemUseability.Remote) != 0) return true; + return false; + } + + private bool IsWieldedByPlayer(ClientObject item) + => IsEquippedByPlayer(item); + + private bool IsEquippedByPlayer(ClientObject item) + { + uint player = _playerGuid(); + return item.CurrentlyEquippedLocation != EquipMask.None + && (item.WielderId == player || item.ContainerId == player); + } + + private bool IsCarriedByPlayer(ClientObject item) + { + uint player = _playerGuid(); + uint container = item.ContainerId; + for (int hops = 0; container != 0 && hops < 8; hops++) + { + if (container == player) return true; + container = _objects.Get(container)?.ContainerId ?? 0u; + } + return false; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs new file mode 100644 index 00000000..0b983ca8 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs @@ -0,0 +1,68 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Retail character identity display helpers for gmStatManagementUI. +/// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls +/// AppraisalSystem::InqGenderHeritageDisplay(gender 0x71, heritage 0xBC, 0), +/// then appends the current CharacterTitleTable title when one is active. +/// +internal static class CharacterIdentityText +{ + public const uint GenderPropertyId = 0x71u; + public const uint HeritageGroupPropertyId = 0xBCu; + + public static string StatHeaderLine(CharacterSheet sheet) + { + string? heritage = !string.IsNullOrWhiteSpace(sheet.Heritage) + ? sheet.Heritage + : sheet.Race; + + string? title = StripLeadingArticle(sheet.Title); + + if (string.IsNullOrWhiteSpace(sheet.Gender)) + return Join(heritage, title); + return Join(sheet.Gender, heritage, title); + } + + public static string? GenderDisplayName(int gender) => gender switch + { + 1 => "Male", + 2 => "Female", + _ => null, + }; + + public static string? HeritageGroupDisplayName(int heritageGroup) => heritageGroup switch + { + 1 => "Aluvian", + 2 => "Gharu'ndim", + 3 => "Sho", + 4 => "Viamontian", + 5 => "Umbraen", + 6 => "Gearknight", + 7 => "Tumerok", + 8 => "Lugian", + 9 => "Empyrean", + 10 => "Penumbraen", + 11 => "Undead", + 12 => "Olthoi", + 13 => "Olthoi", + _ => null, + }; + + private static string Join(params string?[] parts) + { + return string.Join(" ", parts + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p!.Trim())); + } + + private static string? StripLeadingArticle(string? title) + { + if (string.IsNullOrWhiteSpace(title)) return null; + + string trimmed = title.Trim(); + return trimmed.StartsWith("the ", System.StringComparison.OrdinalIgnoreCase) + ? trimmed[4..] + : trimmed; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index 7009ff91..9687f8c5 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -26,10 +26,13 @@ public sealed class CharacterSheet /// Character level. public int Level { get; init; } + /// Gender display string, e.g. "Female". Null = omit. + public string? Gender { get; init; } + /// Race string, e.g. "Aluvian". Null = omit. public string? Race { get; init; } - /// Heritage string, e.g. "Aluvian Heritage". Null = omit. + /// Heritage group display string, e.g. "Aluvian". Null = omit. public string? Heritage { get; init; } /// Title string, e.g. "the Adventurer". Null = omit. @@ -108,11 +111,11 @@ public sealed class CharacterSheet public long UnassignedXp { get; init; } // ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ── - // Retail formula: ExperienceToAttributeLevel(value + 1) − ExperienceToAttributeLevel(value). - // We store pre-computed per-attribute costs for the fixture; cost == 0 → attribute is - // at max or not trainable (raise button ghosted/hidden). Ordered to match AttrRows - // (Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana). - // Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910) + CM_Train::Event_TrainAttribute. + // Retail formula for x1: ExperienceToAttributeLevel(value + 1) − xpSpent. + // Retail formula for x10: ExperienceToAttributeLevel(value + min(10, remaining)) − xpSpent. + // Cost 0 means the attribute is at max or not trainable. Ordered to match AttrRows: + // Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana. + // Source: gmAttributeUI::GetCostToRaise/GetCostToRaise10 (0x0049cb80/0x0049cc70). /// /// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order: @@ -122,6 +125,12 @@ public sealed class CharacterSheet /// public long[] AttributeRaiseCosts { get; init; } = Array.Empty(); + /// + /// XP cost to raise each of the 9 attributes/vitals by up to 10 retail steps, + /// in the same display order as . + /// + public long[] AttributeRaise10Costs { get; init; } = Array.Empty(); + /// /// Skills shown on the Character window Skills tab. Retail gmSkillUI groups these by /// advancement class, then sorts each group alphabetically by skill name. diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs new file mode 100644 index 00000000..4283189f --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -0,0 +1,478 @@ +using System; +using System.Collections.Generic; +using AcDream.Core.Items; +using AcDream.Core.Player; +using DatReaderWriter; + +namespace AcDream.App.UI.Layout; + +/// +/// Assembles the live for the retail Character +/// window and owns the raise-request flow (wire send + optimistic local +/// apply). Extracted from GameWindow (Code Structure Rule 1: sheet +/// assembly + XP-curve math is feature logic, not wiring). +/// +/// Retail property ids and the decomp anchors for every field are +/// documented on ; the raise-cost formulas are +/// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family). +/// +/// State ownership: optimistic debits go through the owning +/// store's eventful APIs — / +/// (fires ObjectUpdated) +/// when the player object is in the table, else +/// / +/// (fires CharacterChanged). +/// Never write the raw property dictionaries from UI code. The next server +/// snapshot remains authoritative over every optimistic value. +/// +public sealed class CharacterSheetProvider +{ + /// PropertyInt64 2 = unassigned (banked) XP — CharacterSheet.UnassignedXp. + private const uint UnassignedXpPropertyId = 2u; + + /// + /// Skill-credit debit fallback chain, most-specific first. Retail reads + /// InqInt(0x18) for the Attributes-tab footer, InqInt(0xc0) for available + /// and InqInt(0xb5) for total skill credits (see CharacterSheet docs); + /// the live server population varies, so debit whichever is present. + /// + private static readonly uint[] SkillCreditPropertyIds = { 0x18u, 0xC0u, 0xB5u }; + + private readonly ClientObjectTable _objects; + private readonly LocalPlayerState _localPlayer; + private readonly Func _playerGuid; + private readonly Func? _activeToonName; + private readonly Func? _fallbackSheet; + private readonly Func? _canSendRaise; + private readonly Action? _sendRaiseAttribute; + private readonly Action? _sendRaiseVital; + private readonly Action? _sendRaiseSkill; + private readonly Action? _sendTrainSkill; + + /// Portal SkillTable (0x0E000004) — set by the host once dats load. + public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; } + + /// Portal ExperienceTable (0x0E000018) — set by the host once dats load. + public DatReaderWriter.DBObjs.ExperienceTable? ExperienceTable { get; set; } + + public CharacterSheetProvider( + ClientObjectTable objects, + LocalPlayerState localPlayer, + Func playerGuid, + Func? activeToonName = null, + Func? fallbackSheet = null, + Func? canSendRaise = null, + Action? sendRaiseAttribute = null, + Action? sendRaiseVital = null, + Action? sendRaiseSkill = null, + Action? sendTrainSkill = null) + { + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer)); + _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _activeToonName = activeToonName; + _fallbackSheet = fallbackSheet; + _canSendRaise = canSendRaise; + _sendRaiseAttribute = sendRaiseAttribute; + _sendRaiseVital = sendRaiseVital; + _sendRaiseSkill = sendRaiseSkill; + _sendTrainSkill = sendTrainSkill; + } + + // ── Sheet assembly ───────────────────────────────────────────────────── + + /// Best display name: active toon key, else the live object's name, else "Player". + public string CharacterName() + { + string? toon = _activeToonName?.Invoke(); + if (!string.IsNullOrWhiteSpace(toon) && toon != "default") + return toon; + uint guid = _playerGuid(); + if (guid != 0u && _objects.Get(guid)?.Name is { Length: > 0 } objectName) + return objectName; + return "Player"; + } + + /// + /// Build the sheet from live state; falls back to the injected sample + /// sheet (Studio data) until any live character data has arrived. + /// Called per repaint by the Character window's bound widgets. + /// + public CharacterSheet BuildSheet() + { + if (!HasLiveData()) + return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() }; + + var props = CurrentPlayerProperties(); + int level = props.GetInt(0x19u); + long totalXp = props.GetInt64(1u); + long unassignedXp = props.GetInt64(UnassignedXpPropertyId); + var xp = ComputeLevelXp(level, totalXp); + + int skillCredits = props.GetInt(0x18u, props.GetInt(0xC0u, props.GetInt(0xB5u))); + + return new CharacterSheet + { + Name = CharacterName(), + Level = level, + Gender = CharacterIdentityText.GenderDisplayName( + props.GetInt(CharacterIdentityText.GenderPropertyId)), + Heritage = CharacterIdentityText.HeritageGroupDisplayName( + props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)), + PkStatus = PkStatusText(props.GetInt(134u, 0)), + TotalXp = totalXp, + XpToNextLevel = xp.toNext, + XpFraction = xp.fraction, + + HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health), + HealthMax = VitalMax(LocalPlayerState.VitalKind.Health), + StaminaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Stamina), + StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina), + ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana), + ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana), + + Strength = AttrCurrent(LocalPlayerState.AttributeKind.Strength), + Endurance = AttrCurrent(LocalPlayerState.AttributeKind.Endurance), + Coordination = AttrCurrent(LocalPlayerState.AttributeKind.Coordination), + Quickness = AttrCurrent(LocalPlayerState.AttributeKind.Quickness), + Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus), + Self = AttrCurrent(LocalPlayerState.AttributeKind.Self), + + UnspentSkillCredits = props.GetInt(0xB5u, skillCredits), + SpecializedSkillCredits = props.GetInt(0xC0u), + SkillCredits = skillCredits, + UnassignedXp = unassignedXp, + AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1), + AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10), + Skills = BuildLiveCharacterSkills(), + BurdenCurrent = props.GetInt(5u), + BurdenMax = props.GetInt(96u), + }; + } + + private bool HasLiveData() + { + var props = CurrentPlayerProperties(); + return props.Ints.Count > 0 + || props.Int64s.Count > 0 + || _localPlayer.Skills.Count > 0 + || _localPlayer.GetAttribute(LocalPlayerState.AttributeKind.Strength) is not null + || _localPlayer.Get(LocalPlayerState.VitalKind.Health) is not null; + } + + /// The player's canonical bundle: the live ClientObject's when the + /// player object has arrived (CreateObject merge-upsert), else the + /// PlayerDescription snapshot on LocalPlayerState. + private PropertyBundle CurrentPlayerProperties() + { + uint guid = _playerGuid(); + return guid != 0u && _objects.Get(guid) is { } player + ? player.Properties + : _localPlayer.Properties; + } + + /// + /// Load the portal ExperienceTable (0x0E000018), falling back to a + /// type scan for older or odd dat collections. Failures are logged — + /// never silently swallowed — and leave raise costs unavailable (0). + /// + public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable( + DatCollection dats, Action? log = null) + { + if (dats is null) return null; + + try + { + var table = dats.Get(0x0E000018u); + if (table is not null) return table; + } + catch (Exception ex) + { + log?.Invoke($"[D.2b-C] ExperienceTable 0x0E000018 read failed ({ex.GetType().Name}: {ex.Message}); trying type scan."); + } + + try + { + foreach (uint id in dats.GetAllIdsOfType()) + { + var table = dats.Get(id); + if (table is not null) return table; + } + } + catch (Exception ex) + { + log?.Invoke($"[D.2b-C] ExperienceTable type scan failed ({ex.GetType().Name}: {ex.Message}); raise costs unavailable."); + } + + return null; + } + + /// XP still needed for the next level + fill fraction of the + /// current level band (retail (cur−base)/(cap−base); CharacterSheet.XpFraction). + private (long toNext, float fraction) ComputeLevelXp(int level, long totalXp) + { + var levels = ExperienceTable?.Levels; + if (levels is null || level < 0 || level + 1 >= levels.Length) + return (0L, 0f); + + long current = ClampToLong(levels[level]); + long next = ClampToLong(levels[level + 1]); + if (next <= current) return (0L, 0f); + + long clampedXp = totalXp < current ? current : totalXp > next ? next : totalXp; + long toNext = next - clampedXp; + float fraction = (float)(clampedXp - current) / (next - current); + return (toNext, fraction); + } + + /// Per-attribute/vital raise costs in retail display order + /// (see CharacterSheet.AttributeRaiseCosts). Cost 0 = maxed / unknown. + private long[] BuildAttributeRaiseCosts(int amount) + { + var xp = ExperienceTable; + return new[] + { + AttributeRaiseCost(LocalPlayerState.AttributeKind.Strength), + AttributeRaiseCost(LocalPlayerState.AttributeKind.Endurance), + AttributeRaiseCost(LocalPlayerState.AttributeKind.Coordination), + AttributeRaiseCost(LocalPlayerState.AttributeKind.Quickness), + AttributeRaiseCost(LocalPlayerState.AttributeKind.Focus), + AttributeRaiseCost(LocalPlayerState.AttributeKind.Self), + VitalRaiseCost(LocalPlayerState.VitalKind.Health), + VitalRaiseCost(LocalPlayerState.VitalKind.Stamina), + VitalRaiseCost(LocalPlayerState.VitalKind.Mana), + }; + + long AttributeRaiseCost(LocalPlayerState.AttributeKind kind) + { + var attr = _localPlayer.GetAttribute(kind); + return attr is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Attributes, attr.Value.Ranks, attr.Value.Xp, amount); + } + + long VitalRaiseCost(LocalPlayerState.VitalKind kind) + { + var vital = _localPlayer.Get(kind); + return vital is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Vitals, vital.Value.Ranks, vital.Value.Xp, amount); + } + } + + private IReadOnlyList BuildLiveCharacterSkills() + { + var result = new List(); + var skillTable = SkillTable; + var xp = ExperienceTable; + + foreach (var snapshot in _localPlayer.Skills.Values) + { + var advancement = AdvancementFromStatus(snapshot.Status); + if (advancement == CharacterSkillAdvancementClass.Inactive) + continue; + + DatReaderWriter.Types.SkillBase? skillBase = null; + if (skillTable?.Skills is not null) + skillTable.Skills.TryGetValue((DatReaderWriter.Enums.SkillId)snapshot.SkillId, out skillBase); + + string? name = skillBase?.Name.Value; + if (string.IsNullOrWhiteSpace(name)) + name = $"Skill {snapshot.SkillId}"; + + uint icon = skillBase?.IconId.DataId ?? 0u; + int trainedCost = skillBase?.TrainedCost ?? 0; + int specializedCost = skillBase?.SpecializedCost ?? 0; + long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1); + long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10); + + result.Add(new CharacterSkill( + snapshot.SkillId, + name, + icon, + advancement, + checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)), + checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)), + IsUsableUntrained(snapshot.SkillId), + trainedCost, + specializedCost, + raiseCost, + raise10Cost)); + } + + return result; + } + + private static CharacterSkillAdvancementClass AdvancementFromStatus(uint status) => status switch + { + 1u => CharacterSkillAdvancementClass.Untrained, + 2u => CharacterSkillAdvancementClass.Trained, + 3u => CharacterSkillAdvancementClass.Specialized, + _ => CharacterSkillAdvancementClass.Inactive, + }; + + private static bool IsUsableUntrained(uint skillId) => skillId switch + { + 18u or 37u or 38u or 39u or 40u => false, + _ => true, + }; + + private static long SkillRaiseCost( + DatReaderWriter.DBObjs.ExperienceTable? xp, + CharacterSkillAdvancementClass advancement, + LocalPlayerState.SkillSnapshot skill, + int amount) + { + if (xp is null) return 0L; + uint[] curve = advancement == CharacterSkillAdvancementClass.Specialized + ? xp.SpecializedSkills + : xp.TrainedSkills; + return RaiseCostFromXpCurve(curve, skill.Ranks, skill.Xp, amount); + } + + /// Cost to advance ranks along a retail + /// cumulative-XP curve: curve[target] − xpAlreadySpent, clamped at the + /// curve end (retail GetCostToRaise/GetCostToRaise10 0x0049cb80/0x0049cc70). + private static long RaiseCostFromXpCurve(uint[]? curve, uint ranks, uint spentXp, int amount) + { + if (curve is null || amount <= 0) return 0L; + long maxIndex = curve.Length - 1L; + if (maxIndex <= ranks) return 0L; + long targetLong = Math.Min((long)ranks + amount, maxIndex); + long targetXp = curve[(int)targetLong]; + long cost = targetXp - spentXp; + return cost > 0 ? cost : 0L; + } + + private static long ClampToLong(ulong value) => + value > long.MaxValue ? long.MaxValue : (long)value; + + private static string? PkStatusText(int status) => status switch + { + 0x2 => "Non-Player Killer", + 0x4 => "Player Killer", + 0x40 => "Player Killer Lite", + _ => null, + }; + + private int AttrCurrent(LocalPlayerState.AttributeKind kind) => + _localPlayer.GetAttribute(kind) is { } attr ? checked((int)Math.Min(int.MaxValue, attr.Current)) : 0; + + private int VitalCurrent(LocalPlayerState.VitalKind kind) => + _localPlayer.Get(kind) is { } vital ? checked((int)Math.Min(int.MaxValue, vital.Current)) : 0; + + private int VitalMax(LocalPlayerState.VitalKind kind) => + _localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0; + + // ── Raise-request flow ───────────────────────────────────────────────── + + /// + /// Send a raise/train action to the server and, when a send delegate + /// fired, optimistically apply the local effect so the sheet stays + /// current during the round trip. The next server snapshot remains + /// authoritative (a rejected raise is corrected by the property echo — + /// pending/rollback ledger tracked as a follow-up issue). + /// + public void HandleRaiseRequest(CharacterStatController.RaiseRequest request) + { + if (request.Cost <= 0) return; + if (_canSendRaise is not null && !_canSendRaise()) return; + + bool sent = false; + switch (request.Kind) + { + case CharacterStatController.RaiseTargetKind.Attribute: + if (_sendRaiseAttribute is not null) + { + _sendRaiseAttribute(request.StatId, (ulong)request.Cost); + sent = true; + } + break; + case CharacterStatController.RaiseTargetKind.Vital: + if (_sendRaiseVital is not null) + { + _sendRaiseVital(request.StatId, (ulong)request.Cost); + sent = true; + } + break; + case CharacterStatController.RaiseTargetKind.Skill: + if (_sendRaiseSkill is not null) + { + _sendRaiseSkill(request.StatId, (ulong)request.Cost); + sent = true; + } + break; + case CharacterStatController.RaiseTargetKind.TrainSkill: + if (_sendTrainSkill is not null && request.Cost <= uint.MaxValue) + { + _sendTrainSkill(request.StatId, (uint)request.Cost); + sent = true; + } + break; + } + + if (sent) + ApplyLocalRaise(request); + } + + private void ApplyLocalRaise(CharacterStatController.RaiseRequest request) + { + uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount; + ulong cost = (ulong)request.Cost; + + switch (request.Kind) + { + case CharacterStatController.RaiseTargetKind.Attribute: + if (_localPlayer.ApplyAttributeRaise(request.StatId, amount, cost)) + SpendUnassignedExperience(request.Cost); + break; + case CharacterStatController.RaiseTargetKind.Vital: + if (_localPlayer.ApplyVitalRaise(request.StatId, amount, cost)) + SpendUnassignedExperience(request.Cost); + break; + case CharacterStatController.RaiseTargetKind.Skill: + if (_localPlayer.ApplySkillRaise(request.StatId, amount, cost)) + SpendUnassignedExperience(request.Cost); + break; + case CharacterStatController.RaiseTargetKind.TrainSkill: + if (_localPlayer.ApplySkillTraining(request.StatId)) + SpendSkillCredits(request.Cost); + break; + } + } + + private void SpendUnassignedExperience(long cost) + { + if (cost <= 0) return; + uint guid = _playerGuid(); + if (guid != 0u && _objects.Get(guid) is { } player) + { + long current = player.Properties.GetInt64(UnassignedXpPropertyId); + if (current <= 0) return; + _objects.UpdateInt64Property(guid, UnassignedXpPropertyId, + current > cost ? current - cost : 0L); + return; + } + _localPlayer.DebitInt64Property(UnassignedXpPropertyId, cost); + } + + private void SpendSkillCredits(long cost) + { + if (cost <= 0) return; + int debit = cost > int.MaxValue ? int.MaxValue : (int)cost; + uint guid = _playerGuid(); + if (guid != 0u && _objects.Get(guid) is { } player) + { + foreach (uint propertyId in SkillCreditPropertyIds) + { + if (!player.Properties.Ints.TryGetValue(propertyId, out int current)) + continue; + _objects.UpdateIntProperty(guid, propertyId, + current > debit ? current - debit : 0); + return; + } + return; + } + foreach (uint propertyId in SkillCreditPropertyIds) + { + if (_localPlayer.DebitIntProperty(propertyId, debit)) + return; + } + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index fd8cc029..578214ca 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -59,7 +59,9 @@ public static class CharacterStatController // The controller binds their LinesProvider instead of injecting new runtime nodes. public const uint XpNextLabelId = 0x10000237u; // "XP for next level:" label child of XP meter public const uint XpNextValueId = 0x10000238u; // XP-to-next-level value child of XP meter - public const uint ListBoxId = 0x1000023Du; // m_pListBox container + public const uint ListBoxId = 0x1000023Du; // m_pListBox container + public const uint ListScrollbarId = 0x1000023Eu; // m_pListBox vertical scrollbar gutter + public const uint ListDividerId = 0x1000023Fu; // bottom divider above footer // ── Footer STATE-A container id ────────────────────────────────────────── // 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row, @@ -95,6 +97,13 @@ public static class CharacterStatController public const uint TabSkillsId = 0x10000229u; // Skills tab group public const uint TabTitlesId = 0x10000538u; // Titles tab group + // Tab page content containers. The imported layout contains duplicated child ids + // inside these pages, so page selection must use the page ids themselves rather + // than layout.FindElement(childId)'s last-registered duplicate. + public const uint AttributesPageId = 0x1000022Bu; + public const uint SkillsPageId = 0x1000022Cu; + public const uint TitlesPageId = 0x10000539u; + // Tab button piece widths and shared height (from dump node rects). private const float TabLeftCapW = 17f; // left-cap button width private const float TabCenterW = 58f; // center label button width @@ -160,12 +169,34 @@ public static class CharacterStatController private const uint SkillHeaderUnusableSprite = 0x06000F89u; private const uint RowHighlightSprite = 0x06001397u; + // Scrollbar chrome from base layout 0x2100003E, shared with chat/inventory. + private const uint ScrollTrackSprite = 0x06004C5Fu; + private const uint ScrollThumbSprite = 0x06004C63u; + private const uint ScrollThumbTop = 0x06004C60u; + private const uint ScrollThumbBot = 0x06004C66u; + private const uint ScrollUpSprite = 0x06004C6Cu; + private const uint ScrollDownSprite = 0x06004C69u; + private enum CharacterStatTab { Attributes, Skills, } + public enum RaiseTargetKind + { + Attribute, + Vital, + Skill, + TrainSkill, + } + + public readonly record struct RaiseRequest( + RaiseTargetKind Kind, + uint StatId, + long Cost, + int Amount); + private sealed class TabVisual { public required CharacterStatTab Tab { get; init; } @@ -179,21 +210,21 @@ public static class CharacterStatController private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill); // ── Attribute row descriptors — retail display order per spec §1 ───────── - private static readonly (string name, uint iconDid)[] AttrRows = new[] + private static readonly (string name, uint iconDid, uint statId)[] AttrRows = new[] { - ("Strength", 0x060002C8u), // enum 1 - ("Endurance", 0x060002C4u), // enum 2 - ("Coordination", 0x060002C9u), // enum 4 - ("Quickness", 0x060002C6u), // enum 3 - ("Focus", 0x060002C5u), // enum 5 - ("Self", 0x060002C7u), // enum 6 + ("Strength", 0x060002C8u, 1u), + ("Endurance", 0x060002C4u, 2u), + ("Coordination", 0x060002C9u, 4u), + ("Quickness", 0x060002C6u, 3u), + ("Focus", 0x060002C5u, 5u), + ("Self", 0x060002C7u, 6u), }; - private static readonly (string name, uint iconDid)[] VitalRows = new[] + private static readonly (string name, uint iconDid, uint maxStatId)[] VitalRows = new[] { - ("Health", 0x06004C3Bu), // CurEnum 2 - ("Stamina", 0x06004C3Cu), // CurEnum 4 - ("Mana", 0x06004C3Du), // CurEnum 6 + ("Health", 0x06004C3Bu, 1u), // max enum 1; current enum 2 + ("Stamina", 0x06004C3Cu, 3u), // max enum 3; current enum 4 + ("Mana", 0x06004C3Du, 5u), // max enum 5; current enum 6 }; /// @@ -218,11 +249,14 @@ public static class CharacterStatController Func data, UiDatFont? datFont = null, UiDatFont? rowDatFont = null, - Func? spriteResolve = null) + Func? spriteResolve = null, + Action? onRaiseRequest = null, + Action? onClose = null) { // rowDatFont: larger font for attribute row name/value text (18px vs 16px default). // Falls back to datFont when null (tests, or dat missing). rowDatFont ??= datFont; + WindowChromeController.BindCloseButton(layout, onClose); var activeTab = new[] { CharacterStatTab.Attributes }; var attrSel = new[] { -1 }; @@ -231,18 +265,19 @@ public static class CharacterStatController var currentAttributeRows = new List(); var currentSkillRows = new List(); var tabVisuals = new List(); + UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId); // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px): // Fix C: pass null → Label's null-guard keeps the build-time dat font. // Controllers still own the text color and the LinesProvider. // Name = WHITE (retail "Horan" is white — confirmed 2026-06-26). - Label(layout, NameId, null, Vector4.One, () => data().Name); - Label(layout, HeritageId, null, Body, () => data().Heritage ?? data().Race ?? string.Empty); - Label(layout, PkStatusId, null, Body, () => data().PkStatus ?? string.Empty); + Label(layout, contentPage, NameId, null, Vector4.One, () => data().Name); + Label(layout, contentPage, HeritageId, null, Body, () => CharacterIdentityText.StatHeaderLine(data())); + Label(layout, contentPage, PkStatusId, null, Body, () => data().PkStatus ?? string.Empty); // ── Header captions (new — retail labels above/left of each number) ────── // LevelCaption (0x1000023A, 16px from dat): pass null → keep build-time dat font. - LabelTwoLine(layout, LevelCaptionId, null, Body, "Character", "Level"); + LabelTwoLine(layout, contentPage, LevelCaptionId, null, Body, "Character", "Level"); // Level number: retail renders this as large gold centered text in the 65×50 element. // Fix C: the dat FontDid for the level element (0x1000023B) is now applied at build @@ -252,11 +287,11 @@ public static class CharacterStatController // BuildAttributeRows) continue to use datFont directly since they have no dat origin. // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo 0x004f0770. // runtime color, dat carries none. - Label(layout, LevelId, null, Gold, () => data().Level.ToString()); + Label(layout, contentPage, LevelId, null, Gold, () => data().Level.ToString()); // TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font. - LabelLeft(layout, TotalXpLabelId, null, Body, static () => "Total Experience (XP):"); - Label(layout, TotalXpId, null, Body, () => data().TotalXp.ToString("N0")); + LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):"); + Label(layout, contentPage, TotalXpId, null, Body, () => data().TotalXp.ToString("N0")); // XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70). // Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by @@ -267,7 +302,7 @@ public static class CharacterStatController // dat-origin HJustify/VJustify/FontDid/FontColor at build time. The controller then // overrides Padding=0 (to avoid scroll clip in the ~13px-tall bar), ClickThrough=true, // and provides the LinesProvider for runtime content. - if (layout.FindElement(XpMeterId) is UiMeter meter) + if (FindElementByDatId(layout, contentPage, XpMeterId) is UiMeter meter) { meter.Fill = () => data().XpFraction; @@ -276,7 +311,7 @@ public static class CharacterStatController // The retail layout places the caption + value ON TOP of the red bar // (ref 2026-06-26: "value … with the red fill bar behind it"). // Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1). - if (layout.FindElement(XpNextLabelId) is UiText xpLabel) + if (FindTextByDatId(layout, contentPage, XpNextLabelId) is UiText xpLabel) { if (datFont is not null) xpLabel.DatFont = datFont; xpLabel.ClickThrough = true; @@ -290,7 +325,7 @@ public static class CharacterStatController // horizontal offset within the panel (the meter starts to the right of the // "Total Experience (XP):" caption row). Source: retail spec §State 1 (the // "XP for next level:" caption left-aligns with "Total Experience (XP):" above). - if (layout.FindElement(TotalXpLabelId) is { } totalXpLbl) + if (FindElementByDatId(layout, contentPage, TotalXpLabelId) is { } totalXpLbl) { float xpNextLeft = totalXpLbl.Left - meter.Left; xpLabel.Left = xpNextLeft >= 0f ? xpNextLeft : 0f; @@ -298,7 +333,7 @@ public static class CharacterStatController xpLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) }; } - if (layout.FindElement(XpNextValueId) is UiText xpValue) + if (FindTextByDatId(layout, contentPage, XpNextValueId) is UiText xpValue) { if (datFont is not null) xpValue.DatFont = datFont; xpValue.ClickThrough = true; @@ -351,11 +386,34 @@ public static class CharacterStatController if (allRaise1.Count == 0 && layout.FindElement(RaiseOneId) is UiButton b1) allRaise1.Add(b1); if (allRaise10.Count == 0 && layout.FindElement(RaiseTenId) is UiButton b10) allRaise10.Add(b10); + var footerDefaultGroups = new List(); + var footerSelectedGroups = new List(); + var footerInactiveGroups = new List(); + if (contentPage is not null) + { + CollectElementsByDatId(contentPage, FooterStateAId, footerDefaultGroups); + CollectElementsByDatId(contentPage, FooterStateBId, footerSelectedGroups); + CollectElementsByDatId(contentPage, FooterStateCId, footerInactiveGroups); + } + else if (layout.Root is not null) + { + CollectElementsByDatId(layout.Root, FooterStateAId, footerDefaultGroups); + CollectElementsByDatId(layout.Root, FooterStateBId, footerSelectedGroups); + CollectElementsByDatId(layout.Root, FooterStateCId, footerInactiveGroups); + } + + void SetFooterSelected(bool selected) + { + foreach (var g in footerDefaultGroups) g.Visible = !selected; + foreach (var g in footerSelectedGroups) g.Visible = selected; + foreach (var g in footerInactiveGroups) g.Visible = false; + } + // Initial state: raise buttons hidden until a row is selected. foreach (var b in allRaise1) b.Visible = false; foreach (var b in allRaise10) b.Visible = false; - // ── Footer State B/C visibility ─────────────────────────────────────── + // ── Footer state visibility ─────────────────────────────────────────── // There are THREE footer state groups (A=0x10000240, B=0x10000241, C=0x10000247) // all stacked at the same position within the Attributes page. _byId stores only // the LAST copy of each id; the others live in the VISIBLE Attributes page and must @@ -374,35 +432,37 @@ public static class CharacterStatController // and only place for this visibility management. See retail decomp // gmStatManagementUI::GetFooterTitleLabel @0x004f0170. // - // Strategy: walk the Attributes page (the root child that contains the NameId anchor) - // and hide every UiDatElement whose ElementId matches B or C. The page-visibility - // pass hides everything in the Skills/Titles pages, so we only need to act on the - // Attributes page's copies. - if (layout.FindElement(NameId) is { } bAnchor && layout.Root is { } fbRoot) - { - foreach (var fbPage in fbRoot.Children) - { - if (fbPage.Children.Count == 0) continue; - if (!ContainsWidget(fbPage, bAnchor)) continue; - // This is the Attributes page. Hide all stateB and stateC copies in it. - HideAllById(fbPage, FooterStateBId); - HideAllById(fbPage, FooterStateCId); - break; - } - } - // Also hide the last-registered copies (in the _byId dict, which may be from a - // different page). Belt-and-suspenders. - if (layout.FindElement(FooterStateBId) is { } footerB) footerB.Visible = false; - if (layout.FindElement(FooterStateCId) is { } footerC) footerC.Visible = false; + // Strategy: collect the footer groups from the explicit Attributes page id. + // State A is visible when nothing is selected; State B is visible when a row is + // selected and owns the retail raise buttons; State C stays hidden for now. + SetFooterSelected(false); // ── Footer State A initial binding ──────────────────────────────────── // Walk to the State-A container directly (rather than _byId which returns the // last duplicate) so we get the wider-label copies (195px) for the unselected state. - BindFooterDynamic(layout, datFont, data, activeTab, attrSel, skillSel); + BindFooterDynamic(layout, datFont, data, activeTab, attrSel, skillSel, contentPage); + SetFooterSelected(false); UiElement? statList = - FindInPageContaining(layout, NameId, static el => HasDatElementId(el, ListBoxId)) + (contentPage is not null + ? FindInSubtree(contentPage, static el => HasDatElementId(el, ListBoxId)) + : null) ?? layout.FindElement(ListBoxId); + if (statList is not null) + statList.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom; + + if (layout.Root is { } stretchRoot) + { + SetAnchorsAllById(stretchRoot, ListScrollbarId, AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom); + SetAnchorsAllById(stretchRoot, ListDividerId, AnchorEdges.Left | AnchorEdges.Bottom); + SetAnchorsAllById(stretchRoot, FooterStateAId, AnchorEdges.Left | AnchorEdges.Bottom); + SetAnchorsAllById(stretchRoot, FooterStateBId, AnchorEdges.Left | AnchorEdges.Bottom); + SetAnchorsAllById(stretchRoot, FooterStateCId, AnchorEdges.Left | AnchorEdges.Bottom); + } + + UiScrollbar? skillScrollbar = PrepareSkillScrollbar(layout, contentPage, statList, spriteResolve); + WireRaiseButtonClicks(allRaise1, allRaise10, data, activeTab, attrSel, skillSel, + () => currentSkillRows, onRaiseRequest, RefreshAfterRaise); RebuildActiveList(); if (layout.Root is { } tabRoot2 && spriteResolve is not null) @@ -430,12 +490,13 @@ public static class CharacterStatController // visibility encoding for tabs. Tab visibility is managed at runtime by gmTabUI // via SetVisible(bool) on the page containers. The controller is the correct // and only place for initial tab-page selection. - if (layout.FindElement(NameId) is { } anchor && layout.Root is { } root) + if (layout.Root is { } root) { foreach (var page in root.Children) { - if (page.Children.Count == 0) continue; - page.Visible = ContainsWidget(page, anchor); + uint id = DatElementId(page); + if (id is AttributesPageId or SkillsPageId or TitlesPageId) + page.Visible = id == AttributesPageId; } } @@ -445,6 +506,7 @@ public static class CharacterStatController activeTab[0] = tab; attrSel[0] = -1; skillSel[0] = -1; + SetFooterSelected(false); RebuildActiveList(); RefreshActiveRaiseButtons(); UpdateTabVisuals(tabVisuals, tab); @@ -463,14 +525,38 @@ public static class CharacterStatController if (activeTab[0] == CharacterStatTab.Attributes) { + if (skillScrollbar is not null) + { + skillScrollbar.Model = null; + skillScrollbar.Visible = false; + } currentAttributeRows = BuildAttributeRows(statList, rowDatFont, spriteResolve, data, attrSel, - allRaise1, allRaise10); + allRaise1, allRaise10, SetFooterSelected); activeListEntries.AddRange(currentAttributeRows); } else { - activeListEntries.AddRange(BuildSkillRows(statList, rowDatFont, spriteResolve, data, skillSel, - allRaise1, allRaise10, out currentSkillRows)); + float contentW = SkillViewportWidth(statList, skillScrollbar); + var viewport = new UiScrollablePanel + { + Left = 0f, + Top = 0f, + Width = contentW, + Height = statList.Height, + LineHeight = (int)SkillRowHeight, + Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + }; + statList.AddChild(viewport); + activeListEntries.Add(viewport); + + BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel, + allRaise1, allRaise10, SetFooterSelected, out currentSkillRows); + + if (skillScrollbar is not null) + { + skillScrollbar.Model = viewport.Scroll; + skillScrollbar.Visible = true; + } } } @@ -488,6 +574,118 @@ public static class CharacterStatController : null; RefreshSkillRaiseButtons(selectedSkill, data(), allRaise1, allRaise10); } + + void RefreshAfterRaise(uint? selectedSkillId) + { + if (activeTab[0] == CharacterStatTab.Skills) + { + if (selectedSkillId is null + && skillSel[0] >= 0 + && skillSel[0] < currentSkillRows.Count) + { + selectedSkillId = currentSkillRows[skillSel[0]].Skill.Id; + } + + RebuildActiveList(); + + skillSel[0] = -1; + if (selectedSkillId is uint id) + { + for (int i = 0; i < currentSkillRows.Count; i++) + { + if (currentSkillRows[i].Skill.Id == id) + { + skillSel[0] = i; + break; + } + } + } + + ApplySkillSelectionVisuals(skillSel[0], currentSkillRows, spriteResolve); + SetFooterSelected(skillSel[0] >= 0); + } + + RefreshActiveRaiseButtons(); + } + } + + private static UiScrollbar? PrepareSkillScrollbar( + ImportedLayout layout, + UiElement? contentPage, + UiElement? statList, + Func? spriteResolve) + { + if (spriteResolve is null) + return null; + + UiElement? source = + (contentPage is not null + ? FindInSubtree(contentPage, static el => HasDatElementId(el, ListScrollbarId)) + : null) + ?? layout.FindElement(ListScrollbarId); + + if (source is UiScrollbar existingBar) + { + ConfigureSkillScrollbar(existingBar, spriteResolve); + existingBar.Visible = false; + return existingBar; + } + + UiElement? parent = source?.Parent ?? statList?.Parent; + if (parent is null || statList is null) + return null; + + float left = source is not null + ? source.Left + : statList.Left + MathF.Min(statList.Width, SkillContentWidth); + float top = source?.Top ?? statList.Top; + float width = source?.Width > 0f ? source.Width : 16f; + float height = source?.Height > 0f ? source.Height : statList.Height; + int z = (source?.ZOrder ?? statList.ZOrder) + 1; + + if (source is not null) + source.Visible = false; + + var bar = new UiScrollbar + { + Left = left, + Top = top, + Width = width, + Height = height, + ZOrder = z, + Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + }; + ConfigureSkillScrollbar(bar, spriteResolve); + bar.Visible = false; + parent.AddChild(bar); + return bar; + } + + private static void ConfigureSkillScrollbar( + UiScrollbar bar, + Func spriteResolve) + { + bar.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }; + bar.TrackSprite = ScrollTrackSprite; + bar.ThumbSprite = ScrollThumbSprite; + bar.ThumbTopSprite = ScrollThumbTop; + bar.ThumbBotSprite = ScrollThumbBot; + bar.UpSprite = ScrollUpSprite; + bar.DownSprite = ScrollDownSprite; + } + + private static float SkillViewportWidth(UiElement statList, UiScrollbar? bar) + { + if (bar is not null && ReferenceEquals(bar.Parent, statList.Parent)) + { + float widthToGutter = bar.Left - statList.Left; + if (widthToGutter > 32f) + return widthToGutter; + } + + return statList.Width > 0f + ? MathF.Min(statList.Width, SkillContentWidth) + : SkillContentWidth; } // ── 9-row attribute list ───────────────────────────────────────────────── @@ -499,7 +697,8 @@ public static class CharacterStatController Func data, int[] sel, List allRaise1, - List allRaise10) + List allRaise10, + Action setFooterSelected) { float listW = list.Width; float y = 0f; @@ -507,7 +706,7 @@ public static class CharacterStatController for (int i = 0; i < AttrRows.Length; i++) { - var (rowName, iconDid) = AttrRows[i]; + var (rowName, iconDid, _) = AttrRows[i]; int rowIndex = i; var row = AddRow(list, datFont, spriteResolve, @@ -530,14 +729,18 @@ public static class CharacterStatController return v.ToString(); }); - row.OnClick = () => HandleRowClick(rowIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10); + row.OnClick = () => + { + HandleRowClick(rowIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10); + setFooterSelected(sel[0] >= 0); + }; rows.Add(row); y += RowHeight; } for (int i = 0; i < VitalRows.Length; i++) { - var (rowName, iconDid) = VitalRows[i]; + var (rowName, iconDid, _) = VitalRows[i]; int rowIndex = i; int absIndex = AttrRows.Length + i; @@ -557,7 +760,11 @@ public static class CharacterStatController }; }); - row.OnClick = () => HandleRowClick(absIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10); + row.OnClick = () => + { + HandleRowClick(absIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10); + setFooterSelected(sel[0] >= 0); + }; rows.Add(row); y += RowHeight; } @@ -573,6 +780,7 @@ public static class CharacterStatController int[] sel, List allRaise1, List allRaise10, + Action setFooterSelected, out List skillRows) { float listW = list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth; @@ -608,7 +816,11 @@ public static class CharacterStatController valueProvider: () => skill.CurrentLevel.ToString(), valueColorProvider: () => SkillValueColor(skill), nameColor: Vector4.One); - row.OnClick = () => HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10); + row.OnClick = () => + { + HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10); + setFooterSelected(sel[0] >= 0); + }; bindings.Add(new SkillRowBinding(row, skill)); entries.Add(row); y += SkillRowHeight; @@ -774,10 +986,21 @@ public static class CharacterStatController string rowName = newSel >= 0 && newSel < rows.Count ? rows[newSel].Skill.Name : string.Empty; Console.WriteLine($"[CharacterStat] Skill row click: index={clickedIndex} -> selected={newSel} ({rowName})"); + ApplySkillSelectionVisuals(newSel, rows, spriteResolve); + + CharacterSkill? selectedSkill = newSel >= 0 && newSel < rows.Count ? rows[newSel].Skill : null; + RefreshSkillRaiseButtons(selectedSkill, data(), allRaise1, allRaise10); + } + + private static void ApplySkillSelectionVisuals( + int selectedIndex, + IReadOnlyList rows, + Func? spriteResolve) + { for (int i = 0; i < rows.Count; i++) { var row = rows[i].Panel; - if (i == newSel) + if (i == selectedIndex) { if (spriteResolve is not null) { @@ -802,9 +1025,6 @@ public static class CharacterStatController row.UseSelectionBars = true; } } - - CharacterSkill? selectedSkill = newSel >= 0 && newSel < rows.Count ? rows[newSel].Skill : null; - RefreshSkillRaiseButtons(selectedSkill, data(), allRaise1, allRaise10); } /// Refresh raise button visibility + state based on current selection. @@ -828,14 +1048,17 @@ public static class CharacterStatController } var sheet = data(); - long cost = GetRaiseCost(sheet, selectedIndex); - bool affordable = cost > 0 && sheet.UnassignedXp >= cost; + long cost1 = GetRaiseCost(sheet, selectedIndex, amount: 1); + long cost10 = GetRaiseCost(sheet, selectedIndex, amount: 10); + bool affordable1 = cost1 > 0 && sheet.UnassignedXp >= cost1; + bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10; // State "Normal" = affordable/green; "Ghosted" = too expensive or maxed. - string btnState = affordable ? StateNormal : StateGhosted; + string btn1State = affordable1 ? StateNormal : StateGhosted; + string btn10State = affordable10 ? StateNormal : StateGhosted; - foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btnState; } - foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btnState; } + foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btn1State; } + foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btn10State; } } private static void RefreshSkillRaiseButtons( @@ -876,14 +1099,125 @@ public static class CharacterStatController } } + private static void WireRaiseButtonClicks( + List allRaise1, + List allRaise10, + Func data, + CharacterStatTab[] activeTab, + int[] attrSel, + int[] skillSel, + Func> skillRows, + Action? onRaiseRequest, + Action? afterRaiseRequest) + { + foreach (var button in allRaise1) + { + UiButton captured = button; + captured.OnClick = () => HandleRaiseButtonClick( + amount: 1, data, activeTab, attrSel, skillSel, skillRows, onRaiseRequest, afterRaiseRequest); + } + + foreach (var button in allRaise10) + { + UiButton captured = button; + captured.OnClick = () => HandleRaiseButtonClick( + amount: 10, data, activeTab, attrSel, skillSel, skillRows, onRaiseRequest, afterRaiseRequest); + } + } + + private static void HandleRaiseButtonClick( + int amount, + Func data, + CharacterStatTab[] activeTab, + int[] attrSel, + int[] skillSel, + Func> skillRows, + Action? onRaiseRequest, + Action? afterRaiseRequest) + { + if (onRaiseRequest is null) return; + + var sheet = data(); + RaiseRequest? request; + uint? selectedSkillId = null; + + if (activeTab[0] == CharacterStatTab.Attributes) + { + request = TryBuildAttributeRaiseRequest(sheet, attrSel[0], amount); + } + else + { + var rows = skillRows(); + CharacterSkill? selectedSkill = + skillSel[0] >= 0 && skillSel[0] < rows.Count + ? rows[skillSel[0]].Skill + : null; + selectedSkillId = selectedSkill?.Id; + request = TryBuildSkillRaiseRequest(sheet, selectedSkill, amount); + } + + if (request is not { } value) return; + + onRaiseRequest(value); + afterRaiseRequest?.Invoke(selectedSkillId); + } + + private static RaiseRequest? TryBuildAttributeRaiseRequest( + CharacterSheet sheet, + int selectedIndex, + int amount) + { + if (selectedIndex < 0) return null; + + long cost = GetRaiseCost(sheet, selectedIndex, amount); + if (cost <= 0 || sheet.UnassignedXp < cost) return null; + + if (selectedIndex < AttrRows.Length) + return new RaiseRequest(RaiseTargetKind.Attribute, AttrRows[selectedIndex].statId, cost, amount); + + int vitalIndex = selectedIndex - AttrRows.Length; + if (vitalIndex >= 0 && vitalIndex < VitalRows.Length) + return new RaiseRequest(RaiseTargetKind.Vital, VitalRows[vitalIndex].maxStatId, cost, amount); + + return null; + } + + private static RaiseRequest? TryBuildSkillRaiseRequest( + CharacterSheet sheet, + CharacterSkill? selectedSkill, + int amount) + { + if (selectedSkill is null) return null; + + bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained; + if (!trained) + { + if (amount != 1) return null; + long trainCost = selectedSkill.TrainedCost; + return trainCost > 0 && sheet.SkillCredits >= trainCost + ? new RaiseRequest(RaiseTargetKind.TrainSkill, selectedSkill.Id, trainCost, amount) + : null; + } + + long raiseCost = amount == 10 ? selectedSkill.Raise10Cost : selectedSkill.RaiseCost; + return raiseCost > 0 && sheet.UnassignedXp >= raiseCost + ? new RaiseRequest(RaiseTargetKind.Skill, selectedSkill.Id, raiseCost, amount == 10 ? 10 : 1) + : null; + } + /// Return the raise cost for row from the sheet. /// Returns 0 if the cost array is shorter than expected. internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex) + => GetRaiseCost(sheet, rowIndex, amount: 1); + + /// Return the raise cost for row and retail amount. + /// Returns 0 if the relevant cost array is shorter than expected. + internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex, int amount) { - if (sheet.AttributeRaiseCosts is null || rowIndex < 0 - || rowIndex >= sheet.AttributeRaiseCosts.Length) + var costs = amount == 10 ? sheet.AttributeRaise10Costs : sheet.AttributeRaiseCosts; + if (costs is null || rowIndex < 0 || rowIndex >= costs.Length) return 0L; - return sheet.AttributeRaiseCosts[rowIndex]; + return costs[rowIndex]; } /// Return the display name for the row at , @@ -1041,7 +1375,8 @@ public static class CharacterStatController Func data, CharacterStatTab[] activeTab, int[] attrSel, - int[] skillSel) + int[] skillSel, + UiElement? contentPage = null) { // Walk the State A container (0x10000240) to find the wide-label copies of the // footer child elements. The 5 children of 0x10000240 at their LOCAL coords: @@ -1057,26 +1392,18 @@ public static class CharacterStatController // which ends up in the LAST-imported tab page (Titles). The page-visibility pass // hides the Titles page → the bound footer elements would be invisible. // - // Fix: find stateA in the SAME subtree as the anchor element (NameId=0x10000231). - // The anchor is in the Attributes content area (the page the pass keeps Visible=True). - // We walk every root-level content area to find the one that contains the anchor, - // then walk that subtree for the footer container. - UiElement? stateA = null; - if (layout.FindElement(NameId) is { } anchor && layout.Root is { } visRoot) - { - // Find the root-level content page that contains the anchor. - foreach (var page in visRoot.Children) - { - if (page.Children.Count == 0) continue; - if (!ContainsWidget(page, anchor)) continue; - // This page will be Visible=True. Walk it for FooterStateAId. - stateA = FindInSubtree(page, static el => - el is UiDatElement d && d.ElementId == FooterStateAId); - break; - } - } + // Fix: find State A/B inside the explicit Attributes page, not via _byId. The + // id dictionary stores the last duplicate and can point at a hidden Skills/Titles + // page copy. + UiElement? stateA = contentPage is not null + ? FindInSubtree(contentPage, static el => el is UiDatElement d && d.ElementId == FooterStateAId) + : null; + UiElement? stateB = contentPage is not null + ? FindInSubtree(contentPage, static el => el is UiDatElement d && d.ElementId == FooterStateBId) + : null; // Fallback: layout._byId (test layouts with a single page). stateA ??= layout.FindElement(FooterStateAId); + stateB ??= layout.FindElement(FooterStateBId); // Position-based lookup within stateA's children. // If stateA is null or the element isn't found, falls back to layout._byId. @@ -1201,19 +1528,110 @@ public static class CharacterStatController } return sheet.UnassignedXp.ToString("N0"); }); + + BindSelectedFooterState(stateB); + + UiText? TextById(UiElement? state, uint id) + => state is null + ? null + : FindInSubtree(state, el => el is UiText t && t.ElementId == id) as UiText; + + void BindSelectedFooterState(UiElement? state) + { + var title = TextById(state, FooterTitleId); + if (title is not null) + { + title.BackgroundSprite = 0; + title.VerticalJustify = VJustify.Top; + title.ClickThrough = true; + title.LinesProvider = () => + { + if (activeTab[0] == CharacterStatTab.Skills) + { + var skill = SkillAtDisplayIndex(data(), skillSel[0]); + if (skill is null) + return new[] { new UiText.Line("Select a Skill to Improve", Body) }; + + string skillTitle = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained + ? $"{skill.Name}: {skill.CurrentLevel}" + : skill.Name; + return new[] { new UiText.Line(skillTitle, Vector4.One) }; + } + + if (attrSel[0] < 0) + return new[] { new UiText.Line("Select an Attribute to Improve", Body) }; + var sheet = data(); + string name = GetRowName(attrSel[0]); + string value = GetRowValueString(sheet, attrSel[0]); + return new[] { new UiText.Line($"{name}: {value}", Vector4.One) }; + }; + } + + LabelProvider(TextById(state, FooterLine1Label), null, Body, () => + { + if (activeTab[0] == CharacterStatTab.Skills) + { + var skill = SkillAtDisplayIndex(data(), skillSel[0]); + if (skill is null) return "Skill Credits Available:"; + return skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained + ? "Experience To Raise:" + : "Skill Credits To Raise:"; + } + + return attrSel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:"; + }); + + LabelProvider(TextById(state, FooterLine1Value), null, Body, () => + { + var sheet = data(); + if (activeTab[0] == CharacterStatTab.Skills) + { + var skill = SkillAtDisplayIndex(sheet, skillSel[0]); + if (skill is null) return sheet.SkillCredits.ToString(); + long skillCost = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained + ? skill.RaiseCost + : skill.TrainedCost; + return skillCost > 0 ? skillCost.ToString("N0") : "Infinity!"; + } + + if (attrSel[0] < 0) return sheet.SkillCredits.ToString(); + long cost = GetRaiseCost(sheet, attrSel[0]); + return cost > 0 ? cost.ToString("N0") : "Infinity!"; + }); + + LabelProvider(TextById(state, FooterLine2Label), null, Body, () => + { + if (activeTab[0] == CharacterStatTab.Skills) + { + var skill = SkillAtDisplayIndex(data(), skillSel[0]); + if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) + return "Skill Credits Available:"; + } + return "Unassigned Experience:"; + }); + + LabelProvider(TextById(state, FooterLine2Value), null, Body, () => + { + var sheet = data(); + if (activeTab[0] == CharacterStatTab.Skills) + { + var skill = SkillAtDisplayIndex(sheet, skillSel[0]); + if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) + return sheet.SkillCredits.ToString(); + } + return sheet.UnassignedXp.ToString("N0"); + }); + } } // ── Helpers ────────────────────────────────────────────────────────────── - /// Depth-first walk of and its descendants. - /// Sets = false on every - /// whose equals . - private static void HideAllById(UiElement node, uint targetId) + private static void SetAnchorsAllById(UiElement node, uint targetId, AnchorEdges anchors) { if (node is UiDatElement d && d.ElementId == targetId) - node.Visible = false; + node.Anchors = anchors; foreach (var child in node.Children) - HideAllById(child, targetId); + SetAnchorsAllById(child, targetId, anchors); } /// Depth-first search of and its descendants. @@ -1230,42 +1648,59 @@ public static class CharacterStatController return null; } - /// Finds the first descendant matching inside the - /// root-level tab page that contains . This is required for - /// duplicated tab-page ids such as the character list box (0x1000023D): - /// stores only the last duplicate in its id dictionary, which may be in a hidden page. - private static UiElement? FindInPageContaining( - ImportedLayout layout, - uint anchorId, - Func predicate) - { - if (layout.FindElement(anchorId) is not { } anchor || layout.Root is not { } root) - return null; + private static bool HasDatElementId(UiElement element, uint id) + => DatElementId(element) == id; - foreach (var page in root.Children) + private static uint DatElementId(UiElement element) + => element switch { - if (page.Children.Count == 0) continue; - if (!ContainsWidget(page, anchor)) continue; - return FindInSubtree(page, predicate); + UiDatElement datElement => datElement.ElementId, + UiButton button => button.ElementId, + UiMeter meter => meter.ElementId, + UiText text => text.ElementId, + _ => 0u, + }; + + private static UiElement? FindElementByDatId(ImportedLayout layout, UiElement? scope, uint id) + { + if (scope is not null) + { + var scoped = FindInSubtree(scope, el => HasDatElementId(el, id)); + if (scoped is not null) + return scoped; } + return layout.FindElement(id); + } + + private static UiText? FindTextByDatId(ImportedLayout layout, UiElement? scope, uint id) + => FindElementByDatId(layout, scope, id) as UiText; + + private static UiElement? FindDirectChildById(UiElement? root, uint id) + { + if (root is null) return null; + foreach (var child in root.Children) + { + if (DatElementId(child) == id) + return child; + } return null; } - private static bool HasDatElementId(UiElement element, uint id) - => element is UiDatElement datElement && datElement.ElementId == id; - - private static bool ContainsWidget(UiElement node, UiElement target) + private static void CollectElementsByDatId(UiElement node, uint id, List result) { - if (ReferenceEquals(node, target)) return true; - foreach (var c in node.Children) - if (ContainsWidget(c, target)) return true; - return false; + if (DatElementId(node) == id) + result.Add(node); + foreach (var child in node.Children) + CollectElementsByDatId(child, id, result); } private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text) + => Label(layout, null, id, datFont, color, text); + + private static void Label(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, Func text) { - if (layout.FindElement(id) is UiText t) + if (FindTextByDatId(layout, scope, id) is UiText t) { // Null = keep whatever the importer (dat FontDid resolver) set at build time. // Non-null = controller explicit override. @@ -1284,8 +1719,12 @@ public static class CharacterStatController /// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1 level caption). private static void LabelTwoLine(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, string line1, string line2) + => LabelTwoLine(layout, null, id, datFont, color, line1, line2); + + private static void LabelTwoLine(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, + string line1, string line2) { - if (layout.FindElement(id) is UiText t) + if (FindTextByDatId(layout, scope, id) is UiText t) { // Null = keep whatever the importer (dat FontDid resolver) set at build time. if (datFont is not null) t.DatFont = datFont; @@ -1305,8 +1744,11 @@ public static class CharacterStatController /// Padding=0 so a single dat-font line (≈12px) fits cleanly in a small element without /// being clipped by the bottom-pin scroll math (top=Padding, bottom=H-Padding). private static void LabelLeft(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text) + => LabelLeft(layout, null, id, datFont, color, text); + + private static void LabelLeft(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, Func text) { - if (layout.FindElement(id) is UiText t) + if (FindTextByDatId(layout, scope, id) is UiText t) { // Null = keep whatever the importer (dat FontDid resolver) set at build time. if (datFont is not null) t.DatFont = datFont; @@ -1484,6 +1926,7 @@ public static class CharacterStatController /// and thus the same geometry. Collected via reference-equality guard to avoid duplicates. /// /// + // Match by ElementId, not geometry: the x1 and x10 raise buttons are both 30x26. private static void CollectButtonsById( UiElement node, uint targetId, @@ -1491,31 +1934,26 @@ public static class CharacterStatController ImportedLayout layout) { // Find the canonical copy (last registered in _byId) as the geometry reference. - if (layout.FindElement(targetId) is not UiButton canonical) return; + _ = layout; // Walk the tree and collect ALL UiButton instances matching the canonical geometry. // The canonical copy itself will also be found — that's fine; use a HashSet to dedup. var seen = new HashSet(ReferenceEqualityComparer.Instance); - CollectMatchingButtons(node, canonical, seen, result); + CollectMatchingButtons(node, targetId, seen, result); } private static void CollectMatchingButtons( UiElement node, - UiButton canonical, + uint targetId, HashSet seen, List result) { - if (node is UiButton btn - && Math.Abs(btn.Width - canonical.Width) < 0.5f - && Math.Abs(btn.Height - canonical.Height) < 0.5f - && Math.Abs(btn.Left - canonical.Left) < 0.5f - && Math.Abs(btn.Top - canonical.Top) < 0.5f - && seen.Add(btn)) + if (node is UiButton btn && btn.ElementId == targetId && seen.Add(btn)) { result.Add(btn); } foreach (var child in node.Children) - CollectMatchingButtons(child, canonical, seen, result); + CollectMatchingButtons(child, targetId, seen, result); } } diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 7726b96a..8ea3a3e8 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -209,6 +209,12 @@ public sealed class ChatWindowController ?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText"); c.Transcript.DatFont = datFont; c.Transcript.Font = debugFont; + // The imported Type-12 element may inherit HJustify=Center from its dat + // prototype, which makes UiText use the static one-line label path. The + // chat transcript must always use the scrollable multi-line path so it + // caches layout for mouse selection and Ctrl+C. + c.Transcript.Centered = false; + c.Transcript.RightAligned = false; c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont); diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 973fba79..1a8964e6 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -85,6 +85,9 @@ public static class DatWidgetFactory _ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers) }; + e.DatElementId = info.Id; + e.SetStateCursors(info.StateCursors); + // Propagate position + size (pixel-exact from the dat). e.Left = info.X; e.Top = info.Y; @@ -151,6 +154,7 @@ public static class DatWidgetFactory { var m = new UiMeter { + ElementId = info.Id, SpriteResolve = resolve, DatFont = datFont, }; @@ -290,6 +294,7 @@ public static class DatWidgetFactory var t = new UiText { + ElementId = info.Id, BackgroundSprite = bg, SpriteResolve = resolve, Centered = centered, diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index cb4b1552..04e58364 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -96,6 +96,12 @@ public sealed class ElementInfo /// public Dictionary StateMedia = new(); + /// + /// Cursor per state: state name to (RenderSurface file id, hotspot). + /// The "" key represents DirectState, mirroring . + /// + public Dictionary StateCursors = new(); + /// /// The element's initial active state name, taken from ElementDesc.DefaultState.ToString(). /// Normalized to "" when the dat carries Undef/Undefined/0 (no default set). @@ -160,8 +166,8 @@ public static class ElementReader /// (derived placement, not the base prototype's geometry). /// /// - /// : base entries are the default; derived - /// entries override (or add) per state name key. + /// and : + /// base entries are the default; derived entries override (or add) per state name key. /// /// /// : come from the derived element's own tree only. @@ -218,6 +224,9 @@ public static class ElementReader m.StateMedia = new Dictionary(base_.StateMedia); foreach (var kv in derived.StateMedia) m.StateMedia[kv.Key] = kv.Value; + m.StateCursors = new Dictionary(base_.StateCursors); + foreach (var kv in derived.StateCursors) + m.StateCursors[kv.Key] = kv.Value; return m; } } diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 89bf6b1c..c62189de 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -22,6 +22,7 @@ public sealed class InventoryController : IItemListDragHandler public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%") public const uint BurdenCaptionId = 0x100001D7u; // "Burden" public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack" + public const uint TitleTextId = 0x100001D3u; // "Inventory of " public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid) // Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see @@ -45,6 +46,7 @@ public sealed class InventoryController : IItemListDragHandler private readonly Func _playerGuid; private readonly Func _iconIds; private readonly Func _strength; + private readonly Func? _ownerName; private readonly UiItemList? _contentsGrid; private readonly UiItemList? _containerList; @@ -60,6 +62,7 @@ public sealed class InventoryController : IItemListDragHandler private readonly Action? _sendUse; private readonly Action? _sendNoLongerViewing; private readonly Action? _sendPutItemInContainer; // (item, container, placement) + private readonly ItemInteractionController? _itemInteraction; // ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)). private const uint EncumbranceValProperty = 5u; // total carried burden @@ -71,21 +74,28 @@ public sealed class InventoryController : IItemListDragHandler Func playerGuid, Func iconIds, Func strength, + Func? ownerName, UiDatFont? datFont, uint contentsEmptySprite, uint sideBagEmptySprite, uint mainPackEmptySprite, Action? sendUse, Action? sendNoLongerViewing, - Action? sendPutItemInContainer) + Action? sendPutItemInContainer, + ItemInteractionController? itemInteraction, + Action? onClose) { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _strength = strength; + _ownerName = ownerName; _sendUse = sendUse; _sendNoLongerViewing = sendNoLongerViewing; _sendPutItemInContainer = sendPutItemInContainer; + _itemInteraction = itemInteraction; + + WindowChromeController.BindCloseButton(layout, onClose); _contentsGrid = layout.FindElement(ContentsGridId) as UiItemList; _containerList = layout.FindElement(ContainerListId) as UiItemList; @@ -142,6 +152,7 @@ public sealed class InventoryController : IItemListDragHandler // Captions: drive each host UiText directly with the known string (the caption // elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail // (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.) + AttachCaption(layout.FindElement(TitleTextId), () => "Inventory of " + OwnerName(), datFont); AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont); AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont); AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont); @@ -151,6 +162,8 @@ public sealed class InventoryController : IItemListDragHandler _objects.ObjectMoved += OnObjectMoved; _objects.ObjectRemoved += OnObjectChanged; _objects.ObjectUpdated += OnObjectChanged; + if (_itemInteraction is not null) + _itemInteraction.StateChanged += OnInteractionStateChanged; Populate(); } @@ -162,19 +175,23 @@ public sealed class InventoryController : IItemListDragHandler Func iconIds, Func strength, UiDatFont? datFont, + Func? ownerName = null, uint contentsEmptySprite = 0u, uint sideBagEmptySprite = 0u, uint mainPackEmptySprite = 0u, Action? sendUse = null, Action? sendNoLongerViewing = null, - Action? sendPutItemInContainer = null) - => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, + Action? sendPutItemInContainer = null, + ItemInteractionController? itemInteraction = null, + Action? onClose = null) + => new InventoryController(layout, objects, playerGuid, iconIds, strength, ownerName, datFont, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, - sendUse, sendNoLongerViewing, sendPutItemInContainer); + sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose); private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } private void OnObjectMoved(ClientObject o, uint from, uint to) { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } + private void OnInteractionStateChanged() => ApplyIndicators(); /// True if the object is in (or wielded by) the player — i.e. a rebuild is warranted. private bool Concerns(ClientObject o) @@ -200,7 +217,7 @@ public sealed class InventoryController : IItemListDragHandler { var item = _objects.Get(guid); if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; - bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; + bool isBag = IsBag(item); if (isBag) AddCell(_containerList, guid, isContainer: true); } @@ -210,7 +227,7 @@ public sealed class InventoryController : IItemListDragHandler { var item = _objects.Get(guid); if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; - bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; + bool isBag = IsBag(item); if (!isBag) AddCell(_contentsGrid, guid, isContainer: false); } @@ -248,7 +265,12 @@ public sealed class InventoryController : IItemListDragHandler var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u)); main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; - main.Clicked = () => OpenContainer(p); + main.Clicked = () => + { + if (_itemInteraction?.AcquireSelfTarget() == true) return; + OpenContainer(p); + }; + main.DoubleClicked = () => _itemInteraction?.ActivateItem(p); SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity) _topContainer.AddItem(main); } @@ -261,6 +283,11 @@ public sealed class InventoryController : IItemListDragHandler /// Resolved live (not cached at ctor) so a late-arriving player guid is handled. The default /// sentinel is 0; once the main pack is explicitly opened, _openContainer holds the player /// guid instead — both resolve here to the same main-pack container, so the paths are equivalent. + private static bool IsBag(ClientObject item) => + item.ContainerTypeHint != 0u + || item.Type.HasFlag(ItemType.Container) + || item.ItemsCapacity > 0; + private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); /// Add a populated cell wired to its click role: container cell → open+select, @@ -276,6 +303,7 @@ public sealed class InventoryController : IItemListDragHandler cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list) cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed) cell.DragRejectSprite = 0x060011F8u; // red circle + cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid); if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); } else cell.Clicked = () => SelectItem(guid); list.AddItem(cell); @@ -405,7 +433,8 @@ public sealed class InventoryController : IItemListDragHandler { var cell = list.GetItem(i); if (cell is null) continue; - cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem; + bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true; + cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem && !pendingTargetSource; cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open; } } @@ -417,6 +446,12 @@ public sealed class InventoryController : IItemListDragHandler return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack"); } + private string OwnerName() + { + var name = _ownerName?.Invoke(); + return string.IsNullOrWhiteSpace(name) ? "Player" : name; + } + private void AttachCaption(UiElement? host, Func text, UiDatFont? datFont) { if (host is null) return; @@ -487,5 +522,7 @@ public sealed class InventoryController : IItemListDragHandler _objects.ObjectMoved -= OnObjectMoved; _objects.ObjectRemoved -= OnObjectChanged; _objects.ObjectUpdated -= OnObjectChanged; + if (_itemInteraction is not null) + _itemInteraction.StateChanged -= OnInteractionStateChanged; } } diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 43a60108..23f9fdd7 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -394,19 +394,27 @@ public static class LayoutImporter /// /// Read the first from into - /// info.StateMedia[name] and extract the font DID from property 0x1A + /// info.StateMedia[name], read any into + /// info.StateCursors[name], and extract the font DID from property 0x1A /// (ArrayBaseProperty → DataIdBaseProperty) if not yet set. /// private static void ReadState(StateDesc sd, string name, ElementInfo info) { - // Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars) - // are intentionally skipped — cursor behavior is Plan 2. + bool imageRead = false; foreach (var m in sd.Media) { - if (m is MediaDescImage img && img.File != 0) + if (!imageRead && m is MediaDescImage img && img.File != 0) { info.StateMedia[name] = (img.File, (int)img.DrawMode); - break; + imageRead = true; + } + + if (m is MediaDescCursor cursor && cursor.File != 0) + { + info.StateCursors[name] = new UiCursorMedia( + cursor.File, + checked((int)cursor.XHotspot), + checked((int)cursor.YHotspot)); } } diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index 28841b0d..c29f6fff 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -75,6 +75,7 @@ public sealed class PaperdollController : IItemListDragHandler private readonly Func _playerGuid; private readonly Func _iconIds; private readonly Action? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A + private readonly ItemInteractionController? _itemInteraction; private readonly List<(EquipMask Mask, UiItemList List)> _slots = new(); // ── Slots-toggle state ──────────────────────────────────────────────────────────────────────── @@ -85,9 +86,10 @@ public sealed class PaperdollController : IItemListDragHandler private PaperdollController( ImportedLayout layout, ClientObjectTable objects, Func playerGuid, Func iconIds, Action? sendWield, - uint emptySlotSprite, UiDatFont? datFont) + uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction) { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield; + _itemInteraction = itemInteraction; for (int i = 0; i < SlotMap.Length; i++) { @@ -97,6 +99,13 @@ public sealed class PaperdollController : IItemListDragHandler list.Cell.SourceKind = ItemDragSource.Equipment; list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live + list.Cell.UseTargetGuidProvider = _playerGuid; + list.Cell.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); }; + list.Cell.DoubleClicked = () => + { + if (list.Cell.ItemId != 0) + _itemInteraction?.ActivateItem(list.Cell.ItemId); + }; // 3D character (the "figure" — Slice 1/2 correction: NOT a per-slot // silhouette) is the doll viewport, which arrives in Slice 2 with the Slots toggle. // Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring @@ -140,8 +149,9 @@ public sealed class PaperdollController : IItemListDragHandler public static PaperdollController Bind( ImportedLayout layout, ClientObjectTable objects, Func playerGuid, Func iconIds, Action? sendWield = null, - uint emptySlotSprite = 0u, UiDatFont? datFont = null) - => new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont); + uint emptySlotSprite = 0u, UiDatFont? datFont = null, + ItemInteractionController? itemInteraction = null) + => new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont, itemInteraction); private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } private void OnObjectMoved(ClientObject o, uint from, uint to) @@ -209,7 +219,7 @@ public sealed class PaperdollController : IItemListDragHandler { var item = _objects.Get(payload.ObjId); if (item is null) return; - EquipMask wieldMask = item.ValidLocations & MaskFor(targetList); + EquipMask wieldMask = ItemEquipRules.ResolvePaperdollDropWieldMask(item, MaskFor(targetList)); if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected) _objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask); _sendWield?.Invoke(payload.ObjId, (uint)wieldMask); diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs new file mode 100644 index 00000000..0da5ab3d --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs @@ -0,0 +1,108 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Mounts an imported retail window's content into the shared 8-piece beveled +/// chrome () and optionally registers it with +/// the window manager. This is THE mount recipe every +/// retail window follows: frame sized content + 2×border, content inset by +/// the border with Draggable/Resizable off (the frame owns move/resize). +/// +/// The vitals/chat/toolbar/inventory windows still inline this recipe +/// in GameWindow (pre-extraction); new windows must mount through this +/// helper instead of copying the block again — see +/// docs/research/2026-07-02-ui-architecture-review.md (theme T1). +/// +public static class RetailWindowFrame +{ + /// Per-window placement + behavior knobs. Null-valued knobs keep + /// the widget defaults ( / ). + public sealed record Options + { + /// Initial window position (screen px). + public float Left { get; init; } + public float Top { get; init; } + + /// Minimum frame size; null = lock to content + 2×border (non-shrinkable). + public float? MinWidth { get; init; } + public float? MinHeight { get; init; } + + /// Maximum frame height while resizing; null = unbounded. + public float? MaxHeight { get; init; } + + /// Allow horizontal resize (frame default: true). + public bool ResizeX { get; init; } = true; + + /// Restrict which edges start a resize; null = all edges. + public ResizeEdges? ResizableEdges { get; init; } + + /// Window translucency (retail SetDefaultOpacity analog). + public float Opacity { get; init; } = 1f; + + /// Start shown or hidden (hidden windows toggle via the window manager). + public bool Visible { get; init; } = true; + + /// How the content tracks the frame when it resizes. + public AnchorEdges ContentAnchors { get; init; } = + AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom; + + /// Force the content's ClickThrough; null = leave as imported. + public bool? ContentClickThrough { get; init; } + + /// Register the frame under this name in the UiRoot window + /// manager (); null = unmanaged window. + public string? WindowName { get; init; } + } + + /// + /// Wrap (sized by the importer) in the beveled + /// chrome, add it to , and register it when + /// is set. Returns the frame. + /// + public static UiNineSlicePanel Mount( + UiRoot root, + UiElement content, + Func resolveChrome, + Options options) + { + ArgumentNullException.ThrowIfNull(root); + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(resolveChrome); + ArgumentNullException.ThrowIfNull(options); + + const int border = RetailChromeSprites.Border; + float contentW = content.Width; + float contentH = content.Height; + + var frame = new UiNineSlicePanel(resolveChrome) + { + Left = options.Left, + Top = options.Top, + Width = contentW + 2 * border, + Height = contentH + 2 * border, + MinWidth = options.MinWidth ?? contentW + 2 * border, + MinHeight = options.MinHeight ?? contentH + 2 * border, + ResizeX = options.ResizeX, + Opacity = options.Opacity, + Visible = options.Visible, + }; + if (options.MaxHeight is { } maxHeight) frame.MaxHeight = maxHeight; + if (options.ResizableEdges is { } edges) frame.ResizableEdges = edges; + + content.Left = border; + content.Top = border; + content.Width = contentW; + content.Height = contentH; + content.Anchors = options.ContentAnchors; + if (options.ContentClickThrough is { } clickThrough) content.ClickThrough = clickThrough; + content.Draggable = false; // the frame owns window move/resize + content.Resizable = false; + + frame.AddChild(content); + root.AddChild(frame); + if (options.WindowName is { } name) + root.RegisterWindow(name, frame); + return frame; + } +} diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index e4fd00c4..26a8029b 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -54,8 +54,15 @@ public sealed class ToolbarController : IItemListDragHandler private static readonly uint[] CombatIndicatorIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u }; + private const uint CharacterButtonId = 0x10000199u; + private const uint InventoryButtonId = 0x100001B1u; + private const string ButtonClosedState = "Normal"; + private const string ButtonOpenState = "Highlight"; + private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length]; private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length]; + private readonly UiButton? _characterButton; + private readonly UiButton? _inventoryButton; private readonly ClientObjectTable _repo; private readonly Func> _shortcuts; private readonly Func _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex @@ -64,6 +71,7 @@ public sealed class ToolbarController : IItemListDragHandler private bool _storeLoaded; private readonly Action? _sendAddShortcut; // (index, objectGuid) private readonly Action? _sendRemoveShortcut; // (index) + private readonly ItemInteractionController? _itemInteraction; // Digit sprite DID arrays for slot labels (top row, numbers 1-9). // Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346. @@ -87,6 +95,7 @@ public sealed class ToolbarController : IItemListDragHandler uint[]? peaceDigits, uint[]? warDigits, uint[]? emptyDigits, + ItemInteractionController? itemInteraction = null, Action? sendAddShortcut = null, Action? sendRemoveShortcut = null) { @@ -97,6 +106,7 @@ public sealed class ToolbarController : IItemListDragHandler _peaceDigits = peaceDigits; _warDigits = warDigits; _emptyDigits = emptyDigits; + _itemInteraction = itemInteraction; _sendAddShortcut = sendAddShortcut; _sendRemoveShortcut = sendRemoveShortcut; @@ -120,6 +130,9 @@ public sealed class ToolbarController : IItemListDragHandler for (int i = 0; i < CombatIndicatorIds.Length; i++) _combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]); + _characterButton = layout.FindElement(CharacterButtonId) as UiButton; + _inventoryButton = layout.FindElement(InventoryButtonId) as UiButton; + // Hide target-object meters + stack slider (gmToolbarUI::PostInit). foreach (var id in HiddenIds) if (layout.FindElement(id) is { } e) e.Visible = false; @@ -201,16 +214,44 @@ public sealed class ToolbarController : IItemListDragHandler uint[]? peaceDigits = null, uint[]? warDigits = null, uint[]? emptyDigits = null, + ItemInteractionController? itemInteraction = null, Action? sendAddShortcut = null, Action? sendRemoveShortcut = null) { var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState, - peaceDigits, warDigits, emptyDigits, + peaceDigits, warDigits, emptyDigits, itemInteraction, sendAddShortcut, sendRemoveShortcut); c.Populate(); return c; } + /// Wire the retail status-bar launch buttons to top-level window toggles. + public void BindWindowToggles(Action? toggleInventory, Action? toggleCharacter) + { + if (_inventoryButton is not null) + { + if (_itemInteraction is not null) + _inventoryButton.UseTargetGuidProvider = () => _itemInteraction.PlayerGuid; + _inventoryButton.OnClick = () => + { + if (_itemInteraction?.AcquireSelfTarget() == true) return; + toggleInventory?.Invoke(); + }; + } + if (_characterButton is not null) + _characterButton.OnClick = toggleCharacter; + } + + public void SetInventoryOpen(bool open) => SetButtonOpen(_inventoryButton, open); + + public void SetCharacterOpen(bool open) => SetButtonOpen(_characterButton, open); + + private static void SetButtonOpen(UiButton? button, bool open) + { + if (button is not null) + button.ActiveState = open ? ButtonOpenState : ButtonClosedState; + } + /// /// Port of gmToolbarUI::UpdateFromPlayerDesc: clear all slots, then bind /// each shortcut entry that has a resolved item in the repository. @@ -320,7 +361,12 @@ public sealed class ToolbarController : IItemListDragHandler list.Cell.Clicked = () => { if (list.Cell.ItemId != 0) - _useItem(list.Cell.ItemId); + { + if (_itemInteraction is not null) + _itemInteraction.ActivateItem(list.Cell.ItemId); + else + _useItem(list.Cell.ItemId); + } }; } diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index a187f008..9e2d00f2 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -51,6 +51,8 @@ public sealed class UiDatElement : UiElement /// Falls back to DirectState if the named state is absent. public string ActiveState { get; set; } = ""; + public override string ActiveCursorStateName => ActiveState; + /// Merged for this element. /// Dat file-id → (GL texture handle, native px width, native px height). /// Returns (0,0,0) when the texture is not yet uploaded. diff --git a/src/AcDream.App/UI/Layout/WindowChromeController.cs b/src/AcDream.App/UI/Layout/WindowChromeController.cs new file mode 100644 index 00000000..a07eb9ec --- /dev/null +++ b/src/AcDream.App/UI/Layout/WindowChromeController.cs @@ -0,0 +1,54 @@ +using System; +using AcDream.App.UI; + +namespace AcDream.App.UI.Layout; + +/// +/// Shared bindings for imported retail window chrome. The same max/min button id is +/// reused by several LayoutDesc windows; per-window controllers decide what the click +/// means for their window. +/// +internal static class WindowChromeController +{ + public const uint MaxMinButtonId = 0x1000046Fu; + public const uint InventoryCloseButtonId = 0x100001D2u; + public const uint CharacterCloseButtonId = 0x1000022Au; + + public static int BindCloseButton(ImportedLayout layout, Action? onClose) + { + if (onClose is null) + return 0; + + return BindCloseButton(layout.Root, onClose); + } + + private static int BindCloseButton(UiElement node, Action onClose) + { + int count = TryBindCloseButton(node, onClose) ? 1 : 0; + foreach (var child in node.Children) + count += BindCloseButton(child, onClose); + return count; + } + + private static bool TryBindCloseButton(UiElement element, Action onClose) + { + switch (element) + { + case UiButton button when IsCloseButtonId(button.ElementId): + button.OnClick = onClose; + return true; + + case UiDatElement datElement when IsCloseButtonId(datElement.ElementId): + datElement.ClickThrough = false; + datElement.CapturesPointerDrag = true; + datElement.OnClick = onClose; + return true; + + default: + return false; + } + } + + private static bool IsCloseButtonId(uint id) => + id is MaxMinButtonId or InventoryCloseButtonId or CharacterCloseButtonId; +} diff --git a/src/AcDream.App/UI/RetailCursorCatalog.cs b/src/AcDream.App/UI/RetailCursorCatalog.cs new file mode 100644 index 00000000..07bb474c --- /dev/null +++ b/src/AcDream.App/UI/RetailCursorCatalog.cs @@ -0,0 +1,30 @@ +namespace AcDream.App.UI; + +/// Retail global cursor enum metadata used outside LayoutDesc widget states. +internal static class RetailCursorCatalog +{ + public const uint CursorEnumTable = 6; + + // ClientUISystem::UpdateCursorState (0x00564630): + // TARGET_MODE_USE_TARGET -> enum 0x27 when no SmartBox target is found, + // enum 0x28 when ItemHolder::IsTargetCompatibleWithTargetingObject returns true, + // enum 0x29 when it returns false. UIElementManager::SetCursor is called + // with hotspot (14,14) for all three. + public static bool TryGetGlobalCursor(CursorFeedbackKind kind, out RetailCursorSpec spec) + { + spec = kind switch + { + CursorFeedbackKind.TargetPending => new RetailCursorSpec(0x27u, 14, 14), + CursorFeedbackKind.TargetValid => new RetailCursorSpec(0x28u, 14, 14), + CursorFeedbackKind.TargetInvalid => new RetailCursorSpec(0x29u, 14, 14), + _ => default, + }; + + return spec.IsValid; + } +} + +internal readonly record struct RetailCursorSpec(uint EnumId, int HotspotX, int HotspotY) +{ + public bool IsValid => EnumId != 0; +} diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs new file mode 100644 index 00000000..42218789 --- /dev/null +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using AcDream.Core.Items; + +namespace AcDream.App.UI.Testing; + +/// +/// Snapshot row for the live retained-mode retail UI tree. Coordinates are +/// absolute root/screen pixels. +/// +public sealed record RetailUiProbeElement( + int Index, + int Depth, + string Path, + string TypeName, + string? Name, + uint EventId, + uint DatElementId, + float X, + float Y, + float Width, + float Height, + bool Visible, + bool Enabled, + uint ItemId, + int SlotIndex, + ItemDragSource? SourceKind) +{ + public int CenterX => (int)MathF.Round(X + Width * 0.5f); + public int CenterY => (int)MathF.Round(Y + Height * 0.5f); +} + +public sealed record RetailUiProbeAssertion(bool Success, string Message); + +/// +/// Test/diagnostic harness for the retail-style UI. It drives +/// mouse events, never panel controllers directly, so automated checks exercise +/// the same click, double-click, and drag-drop path as a player. +/// +public sealed class RetailUiAutomationProbe +{ + private readonly UiRoot _root; + private readonly ClientObjectTable _objects; + private readonly Action? _log; + private long _nowMs = 1; + + public RetailUiAutomationProbe( + UiRoot root, + ClientObjectTable objects, + Action? log = null) + { + _root = root ?? throw new ArgumentNullException(nameof(root)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _log = log; + } + + public IReadOnlyList Snapshot(bool visibleOnly = true) + { + var rows = new List(); + Walk(_root, "root", depth: 0, ancestorsVisible: true, visibleOnly, rows); + return rows; + } + + public string DumpText(bool visibleOnly = true) + { + var sb = new StringBuilder(); + foreach (var e in Snapshot(visibleOnly)) + { + sb.Append('[').Append(e.Index.ToString(CultureInfo.InvariantCulture)).Append("] "); + sb.Append(new string(' ', Math.Max(0, e.Depth) * 2)); + sb.Append(e.TypeName); + if (e.DatElementId != 0) sb.Append(" dat=0x").Append(e.DatElementId.ToString("X8", CultureInfo.InvariantCulture)); + if (e.EventId != 0) sb.Append(" event=0x").Append(e.EventId.ToString("X8", CultureInfo.InvariantCulture)); + if (!string.IsNullOrEmpty(e.Name)) sb.Append(" name=").Append(e.Name); + sb.Append(" rect=(").Append(F(e.X)).Append(',').Append(F(e.Y)).Append(',') + .Append(F(e.Width)).Append(',').Append(F(e.Height)).Append(')'); + if (!e.Visible) sb.Append(" hidden"); + if (!e.Enabled) sb.Append(" disabled"); + if (e.ItemId != 0) + { + sb.Append(" item=0x").Append(e.ItemId.ToString("X8", CultureInfo.InvariantCulture)); + sb.Append(" slot=").Append(e.SlotIndex.ToString(CultureInfo.InvariantCulture)); + if (e.SourceKind is { } source) sb.Append(" source=").Append(source); + } + sb.Append(" path=").Append(e.Path); + sb.AppendLine(); + } + return sb.ToString(); + } + + public RetailUiProbeElement? FindByDatElementId(uint datElementId, bool visibleOnly = true) + { + foreach (var e in Snapshot(visibleOnly)) + if (e.DatElementId == datElementId && HasArea(e)) + return e; + return null; + } + + public IReadOnlyList FindAllByDatElementId(uint datElementId, bool visibleOnly = true) + { + var matches = new List(); + foreach (var e in Snapshot(visibleOnly)) + if (e.DatElementId == datElementId) + matches.Add(e); + return matches; + } + + public RetailUiProbeElement? FindByItemId( + uint itemGuid, + ItemDragSource? sourceKind = null, + bool visibleOnly = true) + { + if (itemGuid == 0) return null; + foreach (var e in Snapshot(visibleOnly)) + { + if (e.ItemId != itemGuid) continue; + if (sourceKind is { } source && e.SourceKind != source) continue; + if (!HasArea(e)) continue; + return e; + } + return null; + } + + public bool ClickElement(uint datElementId) + { + var target = FindByDatElementId(datElementId); + if (target is null) return Fail($"element 0x{datElementId:X8} not found"); + ClickAt(target.CenterX, target.CenterY); + return true; + } + + public bool ClickItem(uint itemGuid, ItemDragSource? sourceKind = null) + { + var target = FindByItemId(itemGuid, sourceKind); + if (target is null) return Fail($"item 0x{itemGuid:X8} not found"); + ClickAt(target.CenterX, target.CenterY); + return true; + } + + public bool DoubleClickItem(uint itemGuid, ItemDragSource? sourceKind = null) + { + var target = FindByItemId(itemGuid, sourceKind); + if (target is null) return Fail($"item 0x{itemGuid:X8} not found"); + ClickAt(target.CenterX, target.CenterY); + Advance(80); + ClickAt(target.CenterX, target.CenterY); + return true; + } + + public bool DragItemToElement(uint itemGuid, uint datElementId, ItemDragSource? sourceKind = null) + { + var source = FindByItemId(itemGuid, sourceKind); + if (source is null) return Fail($"drag source item 0x{itemGuid:X8} not found"); + var target = FindByDatElementId(datElementId); + if (target is null) return Fail($"drop target element 0x{datElementId:X8} not found"); + DragAt(source.CenterX, source.CenterY, target.CenterX, target.CenterY); + return true; + } + + public bool DragItemToItem(uint sourceGuid, uint targetGuid, ItemDragSource? sourceKind = null) + { + var source = FindByItemId(sourceGuid, sourceKind); + if (source is null) return Fail($"drag source item 0x{sourceGuid:X8} not found"); + var target = FindByItemId(targetGuid); + if (target is null) return Fail($"drop target item 0x{targetGuid:X8} not found"); + DragAt(source.CenterX, source.CenterY, target.CenterX, target.CenterY); + return true; + } + + public bool DragItemOutside(uint itemGuid, int x, int y, ItemDragSource? sourceKind = null) + { + var source = FindByItemId(itemGuid, sourceKind); + if (source is null) return Fail($"drag source item 0x{itemGuid:X8} not found"); + DragAt(source.CenterX, source.CenterY, x, y); + return true; + } + + public RetailUiProbeAssertion AssertItem( + uint itemGuid, + EquipMask? equippedLocation = null, + uint? containerId = null, + int? slot = null) + { + var item = _objects.Get(itemGuid); + if (item is null) + return Result(false, $"item 0x{itemGuid:X8} missing from object table"); + + if (equippedLocation is { } equip && item.CurrentlyEquippedLocation != equip) + return Result(false, + $"item 0x{itemGuid:X8} equip expected 0x{((uint)equip):X8}, got 0x{((uint)item.CurrentlyEquippedLocation):X8}"); + + if (containerId is { } container && item.ContainerId != container) + return Result(false, + $"item 0x{itemGuid:X8} container expected 0x{container:X8}, got 0x{item.ContainerId:X8}"); + + if (slot is { } slotValue && item.ContainerSlot != slotValue) + return Result(false, + $"item 0x{itemGuid:X8} slot expected {slotValue}, got {item.ContainerSlot}"); + + return Result(true, $"item 0x{itemGuid:X8} matched object-table assertion"); + } + + private void Walk( + UiElement element, + string path, + int depth, + bool ancestorsVisible, + bool visibleOnly, + List rows) + { + bool effectiveVisible = ancestorsVisible && element.Visible; + if (visibleOnly && !effectiveVisible) return; + + if (element is UiItemList list) + list.LayoutCells(); + + var pos = element.ScreenPosition; + uint itemId = 0; + int slotIndex = -1; + ItemDragSource? sourceKind = null; + if (element is UiItemSlot slot) + { + itemId = slot.ItemId; + slotIndex = slot.SlotIndex; + sourceKind = slot.SourceKind; + } + + rows.Add(new RetailUiProbeElement( + rows.Count, + depth, + path, + element.GetType().Name, + element.Name, + element.EventId, + element.DatElementId, + pos.X, + pos.Y, + element.Width, + element.Height, + effectiveVisible, + element.Enabled, + itemId, + slotIndex, + sourceKind)); + + var children = element.Children; + for (int i = 0; i < children.Count; i++) + { + var child = children[i]; + var childPath = $"{path}/{child.GetType().Name}[{i}]"; + if (child.DatElementId != 0) + childPath += $"#0x{child.DatElementId:X8}"; + Walk(child, childPath, depth + 1, effectiveVisible, visibleOnly, rows); + } + } + + private void ClickAt(int x, int y) + { + Advance(16); + _root.OnMouseMove(x, y); + _root.OnMouseDown(UiMouseButton.Left, x, y); + Advance(50); + _root.OnMouseUp(UiMouseButton.Left, x, y); + Advance(16); + } + + private void DragAt(int startX, int startY, int endX, int endY) + { + Advance(16); + _root.OnMouseMove(startX, startY); + _root.OnMouseDown(UiMouseButton.Left, startX, startY); + Advance(16); + _root.OnMouseMove(startX + 5, startY + 5); + Advance(16); + _root.OnMouseMove(endX, endY); + Advance(16); + _root.OnMouseUp(UiMouseButton.Left, endX, endY); + Advance(16); + } + + private void Advance(int ms) + { + _nowMs += ms; + _root.Tick(ms / 1000.0, _nowMs); + } + + private static bool HasArea(RetailUiProbeElement element) + => element.Width > 0f && element.Height > 0f; + + private bool Fail(string message) + { + _log?.Invoke(message); + return false; + } + + private RetailUiProbeAssertion Result(bool success, string message) + { + _log?.Invoke(message); + return new RetailUiProbeAssertion(success, message); + } + + private static string F(float value) + => value.ToString("0.##", CultureInfo.InvariantCulture); +} diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs new file mode 100644 index 00000000..ed17b87d --- /dev/null +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using AcDream.Core.Items; + +namespace AcDream.App.UI.Testing; + +/// +/// Tiny one-thread script runner for . +/// It is intended for local diagnostic launches, not for gameplay. Commands +/// execute on render ticks and use the same input path as +/// physical mouse input. +/// +public sealed class RetailUiAutomationScriptRunner +{ + private readonly RetailUiAutomationProbe _probe; + private readonly Action _log; + private readonly List _commands = new(); + private readonly bool _dumpOnStart; + private readonly string? _loadError; + private int _index; + private int _activeIndex = -1; + private long _elapsedMs; + private long _commandStartMs; + private bool _started; + private bool _completed; + + public RetailUiAutomationScriptRunner( + RetailUiAutomationProbe probe, + string? scriptPath, + bool dumpOnStart, + Action? log = null) + { + _probe = probe ?? throw new ArgumentNullException(nameof(probe)); + _log = log ?? (_ => { }); + _dumpOnStart = dumpOnStart; + + if (!string.IsNullOrWhiteSpace(scriptPath)) + { + try + { + int lineNumber = 0; + foreach (var raw in File.ReadAllLines(scriptPath)) + { + lineNumber++; + var line = StripComment(raw).Trim(); + if (line.Length == 0) continue; + _commands.Add(new ScriptCommand(lineNumber, line, Split(line))); + } + } + catch (Exception ex) + { + _loadError = $"failed to load UI probe script '{scriptPath}': {ex.Message}"; + } + } + } + + public bool Completed => _completed; + + public void Tick(double deltaSeconds) + { + if (_completed) return; + + long addMs = Math.Max(1, (long)Math.Round(deltaSeconds * 1000.0)); + _elapsedMs += addMs; + + if (!_started) + { + _started = true; + if (_loadError is not null) + { + _log(_loadError); + _completed = true; + return; + } + + if (_dumpOnStart) + _log(_probe.DumpText()); + + if (_commands.Count == 0) + { + _completed = true; + return; + } + + _log($"running {_commands.Count} UI probe command(s)"); + } + + int guard = 0; + while (!_completed && _index < _commands.Count && guard++ < 8) + { + if (_activeIndex != _index) + { + _activeIndex = _index; + _commandStartMs = _elapsedMs; + } + + var command = _commands[_index]; + bool finished = Execute(command); + if (!finished) break; + _index++; + _activeIndex = -1; + } + + if (_index >= _commands.Count && !_completed) + { + _completed = true; + _log("UI probe script complete"); + } + } + + private bool Execute(ScriptCommand command) + { + var p = command.Parts; + if (p.Length == 0) return true; + + string verb = p[0].ToLowerInvariant(); + return verb switch + { + "dump" => DoDump(), + "click" => DoClick(command), + "doubleclick" => DoDoubleClick(command), + "drag" => DoDrag(command), + "wait" => DoWait(command), + "sleep" => DoSleep(command), + "assert" => DoAssert(command), + _ => Stop(command, $"unknown command '{p[0]}'"), + }; + } + + private bool DoDump() + { + _log(_probe.DumpText()); + return true; + } + + private bool DoClick(ScriptCommand command) + { + var p = command.Parts; + if (p.Length < 3) return Stop(command, "usage: click element | click item [source]"); + string target = p[1].ToLowerInvariant(); + if (target == "element") + { + if (!TryParseUInt(p[2], out uint datId)) return Stop(command, $"bad element id '{p[2]}'"); + return _probe.ClickElement(datId) || Stop(command, "click element failed"); + } + if (target == "item") + { + if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'"); + return _probe.ClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "click item failed"); + } + return Stop(command, "usage: click element | click item [source]"); + } + + private bool DoDoubleClick(ScriptCommand command) + { + var p = command.Parts; + if (p.Length < 3 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase)) + return Stop(command, "usage: doubleclick item [source]"); + if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'"); + return _probe.DoubleClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "doubleclick item failed"); + } + + private bool DoDrag(ScriptCommand command) + { + var p = command.Parts; + if (p.Length < 5 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase)) + return Stop(command, "usage: drag item element | drag item item | drag item outside "); + if (!TryParseUInt(p[2], out uint sourceGuid)) return Stop(command, $"bad item guid '{p[2]}'"); + + string target = p[3].ToLowerInvariant(); + if (target == "element") + { + if (!TryParseUInt(p[4], out uint datId)) return Stop(command, $"bad element id '{p[4]}'"); + return _probe.DragItemToElement(sourceGuid, datId, ParseSource(p, 5)) + || Stop(command, "drag item to element failed"); + } + + if (target == "item") + { + if (!TryParseUInt(p[4], out uint targetGuid)) return Stop(command, $"bad target item guid '{p[4]}'"); + return _probe.DragItemToItem(sourceGuid, targetGuid, ParseSource(p, 5)) + || Stop(command, "drag item to item failed"); + } + + if (target == "outside") + { + if (p.Length < 6) return Stop(command, "usage: drag item outside [source]"); + if (!TryParseInt(p[4], out int x)) return Stop(command, $"bad x coordinate '{p[4]}'"); + if (!TryParseInt(p[5], out int y)) return Stop(command, $"bad y coordinate '{p[5]}'"); + return _probe.DragItemOutside(sourceGuid, x, y, ParseSource(p, 6)) + || Stop(command, "drag item outside failed"); + } + + return Stop(command, "usage: drag item element/item/outside ..."); + } + + private bool DoWait(ScriptCommand command) + { + var p = command.Parts; + if (p.Length < 2) return Stop(command, "usage: wait item | wait element | wait ms "); + + string target = p[1].ToLowerInvariant(); + if (target == "item") + { + if (p.Length < 3 || !TryParseUInt(p[2], out uint itemGuid)) return Stop(command, "usage: wait item [source] [timeoutMs]"); + if (_probe.FindByItemId(itemGuid, ParseSource(p, 3)) is not null) return true; + return WaitOrTimeout(command, TimeoutMs(p, 3, 10000), $"item 0x{itemGuid:X8}"); + } + + if (target == "element") + { + if (p.Length < 3 || !TryParseUInt(p[2], out uint datId)) return Stop(command, "usage: wait element [timeoutMs]"); + if (_probe.FindByDatElementId(datId) is not null) return true; + return WaitOrTimeout(command, TimeoutMs(p, 3, 10000), $"element 0x{datId:X8}"); + } + + if (target == "ms") + return DoSleep(command); + + return Stop(command, "usage: wait item | wait element | wait ms "); + } + + private bool DoSleep(ScriptCommand command) + { + var p = command.Parts; + string value = p.Length >= 3 && string.Equals(p[0], "wait", StringComparison.OrdinalIgnoreCase) ? p[2] + : p.Length >= 2 ? p[1] : ""; + if (!TryParseInt(value, out int ms) || ms < 0) + return Stop(command, "usage: sleep | wait ms "); + return _elapsedMs - _commandStartMs >= ms; + } + + private bool DoAssert(ScriptCommand command) + { + var p = command.Parts; + if (p.Length < 5 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase)) + return Stop(command, "usage: assert item equip|container|slot "); + if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'"); + + string kind = p[3].ToLowerInvariant(); + RetailUiProbeAssertion result; + if (kind == "equip") + { + if (!TryParseUInt(p[4], out uint mask)) return Stop(command, $"bad equip mask '{p[4]}'"); + result = _probe.AssertItem(itemGuid, equippedLocation: (EquipMask)mask); + } + else if (kind == "container") + { + if (!TryParseUInt(p[4], out uint containerId)) return Stop(command, $"bad container id '{p[4]}'"); + result = _probe.AssertItem(itemGuid, containerId: containerId); + } + else if (kind == "slot") + { + if (!TryParseInt(p[4], out int slot)) return Stop(command, $"bad slot '{p[4]}'"); + result = _probe.AssertItem(itemGuid, slot: slot); + } + else + { + return Stop(command, "usage: assert item equip|container|slot "); + } + + return result.Success || Stop(command, result.Message); + } + + private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label) + { + if (_elapsedMs - _commandStartMs <= timeoutMs) return false; + return Stop(command, $"timed out waiting for {label}"); + } + + private bool Stop(ScriptCommand command, string message) + { + _log($"line {command.LineNumber}: {message}; command: {command.Text}"); + _completed = true; + return false; + } + + private static string StripComment(string line) + { + int hash = line.IndexOf('#'); + int slashes = line.IndexOf("//", StringComparison.Ordinal); + int cut = -1; + if (hash >= 0) cut = hash; + if (slashes >= 0) cut = cut >= 0 ? Math.Min(cut, slashes) : slashes; + return cut >= 0 ? line[..cut] : line; + } + + private static string[] Split(string line) + => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + private static ItemDragSource? ParseSource(string[] parts, int start) + { + for (int i = start; i < parts.Length; i++) + if (TryParseSource(parts[i], out var source)) + return source; + return null; + } + + private static bool TryParseSource(string value, out ItemDragSource source) + { + switch (value.ToLowerInvariant()) + { + case "inventory": + case "pack": + case "backpack": + source = ItemDragSource.Inventory; + return true; + case "shortcut": + case "shortcutbar": + case "toolbar": + source = ItemDragSource.ShortcutBar; + return true; + case "equipment": + case "equip": + case "paperdoll": + source = ItemDragSource.Equipment; + return true; + case "ground": + source = ItemDragSource.Ground; + return true; + default: + source = default; + return false; + } + } + + private static int TimeoutMs(string[] parts, int start, int defaultMs) + { + for (int i = start; i < parts.Length; i++) + if (TryParseInt(parts[i], out int ms) && ms >= 0) + return ms; + return defaultMs; + } + + private static bool TryParseUInt(string value, out uint parsed) + { + if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return uint.TryParse(value[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed); + return uint.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed); + } + + private static bool TryParseInt(string value, out int parsed) + => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed); + + private readonly record struct ScriptCommand(int LineNumber, string Text, string[] Parts); +} diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 440b848c..ea1f4e73 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -38,6 +38,9 @@ public sealed class UiButton : UiElement /// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize). public Action? OnClick { get; set; } + /// The dat element id from . + public uint ElementId => _info.Id; + /// Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame). public string? Label { get; set; } @@ -60,6 +63,8 @@ public sealed class UiButton : UiElement /// public string ActiveState { get; set; } = ""; + public override string ActiveCursorStateName => ActiveState; + /// Merged for this element. /// Dat file-id → (GL texture handle, native px width, native px height). /// Returns (0,0,0) when the texture is not yet uploaded. diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 20c328e4..643518bd 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -9,6 +9,12 @@ namespace AcDream.App.UI; [System.Flags] public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 } +/// Retail dat cursor media attached to a UI state. +public readonly record struct UiCursorMedia(uint File, int HotspotX, int HotspotY) +{ + public bool IsValid => File != 0; +} + /// /// Base class for every UI widget in the retained-mode tree. /// @@ -40,9 +46,56 @@ public abstract class UiElement /// public uint EventId { get; internal set; } + /// + /// Dat LayoutDesc element id for widgets imported from retail UI data. + /// Zero for runtime-created helper widgets. This is intentionally separate + /// from : event ids are runtime-local, while this id is + /// stable across retail layout dumps, decomp references, and UI probes. + /// + public uint DatElementId { get; internal set; } + /// Human-readable name for debugging / FindByName. public string? Name { get; init; } + private readonly Dictionary _stateCursors = new(); + + /// Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState. + public IReadOnlyDictionary StateCursors => _stateCursors; + + /// Active state name used for cursor media. Stateful dat widgets override this. + public virtual string ActiveCursorStateName => ""; + + public void SetStateCursors(IReadOnlyDictionary cursors) + { + _stateCursors.Clear(); + foreach (var kv in cursors) + { + if (kv.Value.IsValid) + _stateCursors[kv.Key] = kv.Value; + } + } + + public UiCursorMedia ActiveCursor() + => CursorForState(ActiveCursorStateName, allowFallback: true); + + public UiCursorMedia CursorForState(string stateName, bool allowFallback = true) + { + if (!string.IsNullOrEmpty(stateName) + && _stateCursors.TryGetValue(stateName, out var named) + && named.IsValid) + return named; + + if (!allowFallback) + return default; + + if (_stateCursors.TryGetValue("", out var direct) && direct.IsValid) + return direct; + if (_stateCursors.TryGetValue("Normal", out var normal) && normal.IsValid) + return normal; + + return default; + } + // ── Geometry ──────────────────────────────────────────────────────── /// X in the parent's local pixel space. public float Left { get; set; } @@ -130,6 +183,14 @@ public abstract class UiElement /// self-driven interior drag such as text selection). Default false. public virtual bool HandlesClick => false; + /// + /// Optional game-object target used by item-use cursor/target mode when this UI + /// element represents a target that is not its visible item icon. Example: + /// the status-bar backpack button and paperdoll both mean "self" for a + /// health-kit-style use-with-target action. + /// + public Func? UseTargetGuidProvider { get; set; } + /// Minimum size enforced while resizing. public float MinWidth { get; set; } = 40f; public float MinHeight { get; set; } = 40f; diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index 1840817d..19c5dd52 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -114,6 +114,9 @@ public sealed class UiHost : System.IDisposable /// Hide a registered window; returns false if the name is unknown. public bool HideWindow(string name) => Root.HideWindow(name); + /// Return the current visibility of a registered window. + public bool IsWindowVisible(string name) => Root.IsWindowVisible(name); + /// Toggle a registered window's visibility; returns the new IsVisible. public bool ToggleWindow(string name) => Root.ToggleWindow(name); diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index eedb6ff3..8d724dcc 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -3,6 +3,16 @@ using System.Collections.Generic; namespace AcDream.App.UI; +/// Cell order for a multi-column . +public enum UiItemListFlow +{ + /// Retail listbox bit 0 set: index advances across columns, then down rows. + RowMajor, + + /// Retail listbox bit 0 clear: index advances down rows, then across columns. + ColumnMajor, +} + /// /// A container of item cells (port of retail UIElement_ItemList, class 0x10000031). /// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the @@ -82,9 +92,16 @@ public sealed class UiItemList : UiElement LayoutCells(); } - /// Grid columns (row-major). 1 = single column. Ignored in fill mode. + /// Grid columns. 1 = single column. Ignored in fill mode. public int Columns { get; set; } = 1; + /// + /// Multi-column list flow. Retail UIElement_ListBox::UpdateLayout uses bit 0 of + /// m_bitField: set = row-major, clear = column-major. Default preserves the + /// existing toolbar/grid behavior; panel controllers select retail-specific flow. + /// + public UiItemListFlow Flow { get; set; } = UiItemListFlow.RowMajor; + /// Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes /// to the whole list (the toolbar single-slot legacy). Set >0 (with CellHeight) for a grid. public float CellWidth { get; set; } @@ -107,8 +124,25 @@ public sealed class UiItemList : UiElement return (col * cellW, row * cellH); } + /// + /// Pixel offset matching retail UIElement_ListBox::CalculateColumn/CalculateRow: + /// row-major when bit 0 is set, column-major when it is clear. + /// + internal static (float x, float y) CellOffset( + int index, int columns, int cellCount, UiItemListFlow flow, float cellW, float cellH) + { + int cols = columns < 1 ? 1 : columns; + if (flow == UiItemListFlow.ColumnMajor) + { + int rows = Math.Max(1, RowCount(cellCount, cols)); + int col = index / rows, row = index % rows; + return (col * cellW, row * cellH); + } + return CellOffset(index, cols, cellW, cellH); + } + /// Position every cell per the current mode: fill (CellWidth<=0) sizes the single - /// cell to the list; grid (CellWidth>0) tiles cells row-major, offset by the scroll position + /// cell to the list; grid (CellWidth>0) tiles cells by , offset by the scroll position /// with whole-row vertical clipping (cells fully outside the view are hidden). internal void LayoutCells() { @@ -137,7 +171,7 @@ public sealed class UiItemList : UiElement for (int i = 0; i < _cells.Count; i++) { - var (x, baseY) = CellOffset(i, cols, CellWidth, CellHeight); + var (x, baseY) = CellOffset(i, cols, _cells.Count, Flow, CellWidth, CellHeight); float top = baseY - scrollY; var cell = _cells[i]; cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight; diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index 10e1d7fe..2642fe3e 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -171,6 +171,9 @@ public sealed class UiItemSlot : UiElement /// a bound slot. Wired by ToolbarController to the use-item callback. public Action? Clicked { get; set; } + /// Invoked by when a double-click lands on a bound slot. + public Action? DoubleClicked { get; set; } + /// public override bool OnEvent(in UiEvent e) { @@ -184,6 +187,9 @@ public sealed class UiItemSlot : UiElement case UiEventType.Click: Clicked?.Invoke(); return true; + case UiEventType.DoubleClick: + DoubleClicked?.Invoke(); + return true; case UiEventType.DragBegin: // Notify the source list's handler so it can lift (remove + wire) — retail diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index 5d7fc535..cf1ccb31 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -16,6 +16,8 @@ namespace AcDream.App.UI; /// public sealed class UiMeter : UiElement { + /// Dat element id, set by the layout importer so duplicated page copies can be scoped. + public uint ElementId { get; set; } /// Fill fraction provider; a null result draws an empty bar. public Func Fill { get; set; } = () => 0f; diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index de31ecda..71bf0c8b 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -44,7 +44,11 @@ public class UiPanel : UiElement } else if (BackgroundColor.W > 0f) { - ctx.DrawRect(0, 0, Width, Height, BackgroundColor); + // Panel fills are backgrounds. Draw them through the sprite/fill + // bucket so children that render as sprites/dat-font glyphs stay on + // top in painter order; DrawRect flushes after sprites and would + // cover text/icons. + ctx.DrawFill(0, 0, Width, Height, BackgroundColor); } if (BorderColor.W > 0f && BorderThickness > 0f) diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 251b66c1..29ce3072 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -71,6 +71,34 @@ public sealed class UiRoot : UiElement /// Current drag source (set between drag-begin and drop/cancel). public UiElement? DragSource { get; private set; } public object? DragPayload { get; private set; } + public bool IsWindowMoveActive => _windowDragTarget is not null; + public ResizeEdges ActiveResizeEdges => _resizeTarget is not null ? _resizeEdges : ResizeEdges.None; + public ResizeEdges HoverResizeEdges + { + get + { + var target = Pick(MouseX, MouseY); + var window = FindWindow(target); + return window is { Resizable: true } + ? HitEdges(window, MouseX, MouseY, ResizeGrip) + : ResizeEdges.None; + } + } + public bool HoverWindowMove + { + get + { + var target = Pick(MouseX, MouseY); + var window = FindWindow(target); + if (target is null || window is not { Draggable: true }) + return false; + if (HoverResizeEdges != ResizeEdges.None) + return false; + return !target.IsDragSource + && !target.CapturesPointerDrag + && !target.HandlesClick; + } + } private (uint tex, int w, int h)? _dragGhost; /// Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag. internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost; @@ -79,12 +107,16 @@ public sealed class UiRoot : UiElement private bool _dragCandidate; private UiElement? _windowDragTarget; private int _windowDragOffX, _windowDragOffY; + private UiElement? _lastClickTarget; + private long _lastClickMs; + private int _lastClickX, _lastClickY; private UiElement? _resizeTarget; private ResizeEdges _resizeEdges; private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH; private int _resizeMouseX, _resizeMouseY; private const int ResizeGrip = 5; // px proximity to an edge to start a resize private const int DragDistanceThreshold = 3; // pixels, retail-observed + private const int DoubleClickDelayMs = 500; // Hover / tooltip tracking. private UiElement? _hoverWidget; @@ -106,6 +138,9 @@ public sealed class UiRoot : UiElement /// Raised on scroll fall-through (world zoom, etc.). public event Action? WorldScrollFallThrough; + /// Raised when a drag is released over no UI element. + public event Action? DragReleasedOutsideUi; + private uint _nextEventId = 0x10000001u; public override void AddChild(UiElement child) @@ -371,6 +406,24 @@ public sealed class UiRoot : UiElement Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y)); BubbleEvent(Captured, in click); + + long now = _nowMs != 0 ? _nowMs : Environment.TickCount64; + bool isDoubleClick = + ReferenceEquals(Captured, _lastClickTarget) + && now - _lastClickMs <= DoubleClickDelayMs + && Math.Abs(x - _lastClickX) <= DragDistanceThreshold + && Math.Abs(y - _lastClickY) <= DragDistanceThreshold; + if (isDoubleClick) + { + var dbl = new UiEvent(Captured.EventId, Captured, UiEventType.DoubleClick, + Data0: (int)flags, + Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y)); + BubbleEvent(Captured, in dbl); + } + _lastClickTarget = Captured; + _lastClickMs = now; + _lastClickX = x; + _lastClickY = y; } else if (btn == UiMouseButton.Right && ContainsAbsolute(Captured, x, y)) { @@ -504,6 +557,10 @@ public sealed class UiRoot : UiElement return true; } + /// Return the current visibility of a registered window. + public bool IsWindowVisible(string name) + => _windows.TryGetValue(name, out var w) && w.Visible; + /// Flip the named window's visibility (Show if hidden, Hide if shown). /// Returns the new IsVisible state (false for an unknown name). public bool ToggleWindow(string name) @@ -575,7 +632,10 @@ public sealed class UiRoot : UiElement Data1: (int)lx, Data2: (int)ly, Payload: DragPayload); t.OnEvent(in e); } - // else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail). + else if (DragPayload is { } payload) + { + DragReleasedOutsideUi?.Invoke(payload, x, y); + } DragSource = null; DragPayload = null; _dragGhost = null; diff --git a/src/AcDream.App/UI/UiScrollablePanel.cs b/src/AcDream.App/UI/UiScrollablePanel.cs new file mode 100644 index 00000000..efc01e30 --- /dev/null +++ b/src/AcDream.App/UI/UiScrollablePanel.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Simple vertical viewport for controller-built row lists. It shares the same +/// pixel scroll model as chat text and item grids, and clips whole rows because +/// the UI renderer does not have a scissor stack yet. +/// +public sealed class UiScrollablePanel : UiPanel +{ + private readonly Dictionary _baseTops = new(ReferenceEqualityComparer.Instance); + + public UiScrollable Scroll { get; } = new(); + + public int LineHeight { get; set; } = 16; + + public int ContentHeight { get; private set; } + + public UiScrollablePanel() + { + BackgroundColor = Vector4.Zero; + BorderColor = Vector4.Zero; + } + + public override void AddChild(UiElement child) + { + base.AddChild(child); + Track(child); + } + + public override bool RemoveChild(UiElement child) + { + bool removed = base.RemoveChild(child); + if (removed) + { + _baseTops.Remove(child); + RecomputeContentHeight(); + } + return removed; + } + + public void ClearContent() + { + foreach (var child in Children.ToArray()) + RemoveChild(child); + _baseTops.Clear(); + ContentHeight = 0; + Scroll.SetScrollY(0); + } + + internal void LayoutScrollableChildren() + { + Scroll.LineHeight = Math.Max(1, LineHeight); + Scroll.ContentHeight = ContentHeight; + Scroll.ViewHeight = Math.Max(0, (int)MathF.Floor(Height)); + Scroll.SetScrollY(Scroll.ScrollY); + + foreach (var child in Children) + { + if (!_baseTops.TryGetValue(child, out float baseTop)) + continue; + + float top = baseTop - Scroll.ScrollY; + child.Top = top; + child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f; + } + } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.Scroll) + { + Scroll.ScrollByLines(-e.Data0); + LayoutScrollableChildren(); + return true; + } + return base.OnEvent(e); + } + + protected override void OnDraw(UiRenderContext ctx) + { + LayoutScrollableChildren(); + base.OnDraw(ctx); + } + + private void Track(UiElement child) + { + child.Anchors = AnchorEdges.None; + _baseTops[child] = child.Top; + RecomputeContentHeight(); + } + + private void RecomputeContentHeight() + { + float bottom = 0f; + foreach (var child in Children) + { + if (!_baseTops.TryGetValue(child, out float top)) + continue; + bottom = MathF.Max(bottom, top + child.Height); + } + ContentHeight = (int)MathF.Ceiling(bottom); + Scroll.SetScrollY(Scroll.ScrollY); + } +} diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 0d9f2b15..6dacf0f0 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -23,6 +23,9 @@ namespace AcDream.App.UI; /// public sealed class UiText : UiElement { + /// Dat element id for imported UIElement_Text widgets. 0 for synthesized text. + public uint ElementId { get; set; } + /// One display line: pre-formatted text + its colour. public readonly record struct Line(string Text, Vector4 Color); diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index df487c77..12de64fd 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -239,9 +239,12 @@ public static class GameEventWiring { var p = GameEvents.ParseWieldObject(e.Payload.Span); if (p is null) return; + uint wielderGuid = p.Value.WielderGuid != 0u + ? p.Value.WielderGuid + : (playerGuid?.Invoke() ?? 0u); items.MoveItem( p.Value.ItemGuid, - newContainerId: p.Value.WielderGuid, + newContainerId: wielderGuid, newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc); items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler) }); @@ -249,7 +252,11 @@ public static class GameEventWiring { var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); if (p is null) return; - items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement); + items.MoveItem( + p.Value.ItemGuid, + p.Value.ContainerGuid, + newSlot: (int)p.Value.Placement, + containerTypeHint: p.Value.ContainerType); items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move }); @@ -262,9 +269,12 @@ public static class GameEventWiring { var p = GameEvents.ParseViewContents(e.Payload.Span); if (p is null) return; - var guids = new uint[p.Value.Items.Count]; - for (int i = 0; i < guids.Length; i++) guids[i] = p.Value.Items[i].Guid; - items.ReplaceContents(p.Value.ContainerGuid, guids); + var entries = new ContainerContentEntry[p.Value.Items.Count]; + for (int i = 0; i < entries.Length; i++) + entries[i] = new ContainerContentEntry( + p.Value.Items[i].Guid, + p.Value.Items[i].ContainerType); + items.ReplaceContents(p.Value.ContainerGuid, entries); }); // B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped @@ -273,7 +283,11 @@ public static class GameEventWiring dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e => { var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span); - if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u); + if (guid is not null) + { + items.MoveItem(guid.Value, newContainerId: 0u); + items.ConfirmMove(guid.Value); + } }); // B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move. @@ -284,8 +298,12 @@ public static class GameEventWiring var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span); if (p is null) return; // B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot. - if (!items.RollbackMove(p.Value.ItemGuid)) - Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} (no pending move)"); + var item = items.Get(p.Value.ItemGuid); + string itemInfo = item is null + ? "unknown" + : $"'{item.Name}' valid=0x{(uint)item.ValidLocations:X8} equip=0x{(uint)item.CurrentlyEquippedLocation:X8} priority=0x{item.Priority:X8} container=0x{item.ContainerId:X8} wielder=0x{item.WielderId:X8}"; + bool rolledBack = items.RollbackMove(p.Value.ItemGuid); + Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} rolledBack={rolledBack} item={itemInfo}"); }); // B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container @@ -366,6 +384,8 @@ public static class GameEventWiring if (localPlayer is not null) { + localPlayer.OnProperties(p.Value.Properties); + foreach (var attr in p.Value.Attributes) { if (attr.Current is uint cur) @@ -408,14 +428,12 @@ public static class GameEventWiring // component; .Ranks is XP-bought additions. Their sum is // the closest we get to ACE's CreatureSkill.Current short // of porting the full Aug/Multiplier/Vitae chain. - if (onSkillsUpdated is not null) + if (localPlayer is not null || onSkillsUpdated is not null) { int runSkill = -1; int jumpSkill = -1; foreach (var s in p.Value.Skills) { - if (s.SkillId != 22u && s.SkillId != 24u) continue; - // K-fix13: total = AttributeFormula(skill, attrs) // + InitLevel (s.Init from wire) // + Ranks (s.Ranks from wire) @@ -428,6 +446,19 @@ public static class GameEventWiring uint formulaBonus = resolveSkillFormulaBonus is not null ? resolveSkillFormulaBonus(s.SkillId, attrCurrents) : 0u; + + localPlayer?.OnSkillUpdate( + skillId: s.SkillId, + ranks: s.Ranks, + status: s.Status, + xp: s.Xp, + init: s.Init, + resistance: s.Resistance, + lastUsed: s.LastUsed, + formulaBonus: formulaBonus); + + if (s.SkillId != 22u && s.SkillId != 24u) continue; + int total = (int)(formulaBonus + s.Init + s.Ranks); if (s.SkillId == 24u) runSkill = total; else if (s.SkillId == 22u) jumpSkill = total; @@ -437,7 +468,7 @@ public static class GameEventWiring $"vitals: PD-skill id={s.SkillId} init={s.Init} ranks={s.Ranks} formulaBonus={formulaBonus} total={total}"); } if (runSkill >= 0 || jumpSkill >= 0) - onSkillsUpdated(runSkill, jumpSkill); + onSkillsUpdated?.Invoke(runSkill, jumpSkill); } // Issue #7 — enchantment block: feed each entry into the @@ -463,8 +494,21 @@ public static class GameEventWiring // actual weenie data via ObjectTableWiring. (Previously this seeded // stubs with WeenieClassId = ContainerType, a misuse — ContainerType // is a 0/1/2 container-kind discriminator, not a weenie class id.) - foreach (var inv in p.Value.Inventory) - items.RecordMembership(inv.Guid); + uint ownerGuid = playerGuid?.Invoke() ?? 0u; + if (ownerGuid != 0u) + { + var entries = new ContainerContentEntry[p.Value.Inventory.Count]; + for (int i = 0; i < entries.Length; i++) + entries[i] = new ContainerContentEntry( + p.Value.Inventory[i].Guid, + p.Value.Inventory[i].ContainerType); + items.ReplaceContents(ownerGuid, entries); + } + else + { + foreach (var inv in p.Value.Inventory) + items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType); + } foreach (var eq in p.Value.Equipped) items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation); diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index b79546ed..5d397542 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -146,6 +146,7 @@ public static class CreateObject // publish it. uint? Useability = null, float? UseRadius = null, + uint? TargetType = null, // D.5.1 (2026-06-17): icon overlay/underlay dat ids from the // WeenieHeader optional tail. IconOverlayId is gated by // WeenieHeaderFlag.IconOverlay (0x40000000) in weenieFlags; @@ -645,6 +646,7 @@ public static class CreateObject // SAFE because IconComposer early-returns on id==0 per layer. uint? useability = null; float? useRadius = null; + uint? targetType = null; uint iconOverlayId = 0; uint iconUnderlayId = 0; uint uiEffects = 0; @@ -707,7 +709,7 @@ public static class CreateObject if ((weenieFlags & 0x00080000u) != 0) // TargetType u32 { if (body.Length - pos < 4) throw new FormatException("trunc TargetType"); - pos += 4; + targetType = ReadU32(body, ref pos); } if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE { @@ -850,7 +852,7 @@ public static class CreateObject physicsState, objectDescriptionFlags, friction, elasticity, IconId: iconId, - Useability: useability, UseRadius: useRadius, + Useability: useability, UseRadius: useRadius, TargetType: targetType, IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId, UiEffects: uiEffects, WeenieClassId: weenieClassId, diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 71c7a4f5..94f30592 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -336,11 +336,14 @@ public static class GameEvents public static WieldObject? ParseWieldObject(ReadOnlySpan payload) { - if (payload.Length < 12) return null; + if (payload.Length < 8) return null; + uint wielderGuid = payload.Length >= 12 + ? BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)) + : 0u; return new WieldObject( BinaryPrimitives.ReadUInt32LittleEndian(payload), BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)), - BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))); + wielderGuid); } /// 0x0022 InventoryPutObjInContainer: server puts item into container slot. diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index e47f8aaa..b8fee4ff 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -81,5 +81,7 @@ public static class ObjectTableWiring ContainersCapacity: s.ContainersCapacity, Structure: s.Structure, MaxStructure: s.MaxStructure, - Workmanship: s.Workmanship); + Workmanship: s.Workmanship, + Useability: s.Useability, + TargetType: s.TargetType); } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index b51fae9d..b1b65cb9 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -82,6 +82,7 @@ public sealed class WorldSession : IDisposable // server publishes it for non-useable display entities. uint? Useability = null, float? UseRadius = null, + uint? TargetType = null, // D.5.1: icon datId from CreateObject WeenieHeader, for toolbar rendering. uint IconId = 0, // D.5.1 (2026-06-17): icon overlay/underlay dat ids from the extended @@ -807,6 +808,7 @@ public sealed class WorldSession : IDisposable parsed.Value.Elasticity, parsed.Value.Useability, parsed.Value.UseRadius, + parsed.Value.TargetType, parsed.Value.IconId, parsed.Value.IconOverlayId, parsed.Value.IconUnderlayId, @@ -1209,6 +1211,34 @@ public sealed class WorldSession : IDisposable SendGameAction(body); } + /// Send retail RaiseAttribute (0x0045). + public void SendRaiseAttribute(uint attrId, ulong xpSpent) + { + uint seq = NextGameActionSequence(); + SendGameAction(CharacterActions.BuildRaiseAttribute(seq, attrId, xpSpent)); + } + + /// Send retail RaiseVital (0x0044). + public void SendRaiseVital(uint vitalId, ulong xpSpent) + { + uint seq = NextGameActionSequence(); + SendGameAction(CharacterActions.BuildRaiseVital(seq, vitalId, xpSpent)); + } + + /// Send retail RaiseSkill (0x0046). + public void SendRaiseSkill(uint skillId, ulong xpSpent) + { + uint seq = NextGameActionSequence(); + SendGameAction(CharacterActions.BuildRaiseSkill(seq, skillId, xpSpent)); + } + + /// Send retail TrainSkill (0x0047). + public void SendTrainSkill(uint skillId, uint credits) + { + uint seq = NextGameActionSequence(); + SendGameAction(CharacterActions.BuildTrainSkill(seq, skillId, credits)); + } + /// Send AddShortcut (0x019C) — pin an item to toolbar slot . /// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern. public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0) @@ -1255,9 +1285,17 @@ public sealed class WorldSession : IDisposable SendGameAction(InteractRequests.BuildUse(seq, targetGuid)); } - /// Send PutItemInContainer (0x0019) — move an item into a container at a slot. placement + /// Send UseWithTarget (0x0035) - use a source item on an acquired target. + /// Retail: CM_Inventory::Event_UseWithTargetEvent. + public void SendUseWithTarget(uint sourceGuid, uint targetGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(InteractRequests.BuildUseWithTarget(seq, sourceGuid, targetGuid)); + } + + /// Send PutItemInContainer (0x0019) - move an item into a container at a slot. placement /// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail: - /// CM_Inventory::Event_PutItemInContainer → ACE Player.HandleActionPutItemInContainer. + /// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer. public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement) { uint seq = NextGameActionSequence(); diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index b058bf20..2b7f10f4 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -117,9 +117,23 @@ public sealed class PropertyBundle public Dictionary InstanceIds { get; } = new(); public int GetInt (uint k, int def = 0) => Ints.TryGetValue(k, out var v) ? v : def; + public long GetInt64 (uint k, long def = 0) => Int64s.TryGetValue(k, out var v) ? v : def; public bool GetBool (uint k, bool def = false) => Bools.TryGetValue(k, out var v) ? v : def; public double GetFloat (uint k, double def = 0) => Floats.TryGetValue(k, out var v) ? v : def; public string GetString(uint k, string def = "") => Strings.TryGetValue(k, out var v) ? v : def; + + public PropertyBundle Clone() + { + var copy = new PropertyBundle(); + foreach (var kv in Ints) copy.Ints[kv.Key] = kv.Value; + foreach (var kv in Int64s) copy.Int64s[kv.Key] = kv.Value; + foreach (var kv in Bools) copy.Bools[kv.Key] = kv.Value; + foreach (var kv in Floats) copy.Floats[kv.Key] = kv.Value; + foreach (var kv in Strings) copy.Strings[kv.Key] = kv.Value; + foreach (var kv in DataIds) copy.DataIds[kv.Key] = kv.Value; + foreach (var kv in InstanceIds) copy.InstanceIds[kv.Key] = kv.Value; + return copy; + } } /// @@ -150,12 +164,20 @@ public sealed class ClientObject public int Value { get; set; } // pyreals public uint ContainerId { get; set; } // parent container ObjectId, or 0 public int ContainerSlot { get; set; } = -1; + /// + /// Retail ContentProfile.m_uContainerProperties hint from + /// PlayerDescription/ViewContents: 0 = loose item list, nonzero = container list. + /// Kept separate from because CreateObject owns real item data. + /// + public uint ContainerTypeHint { get; set; } public bool Attuned { get; set; } public bool Bonded { get; set; } public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded public int ItemsCapacity { get; set; } // main-pack slots (containers) public int ContainersCapacity{ get; set; } // side-pack slots (containers) public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order + public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc + public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility public int Structure { get; set; } // charges/uses remaining public int MaxStructure { get; set; } public float Workmanship { get; set; } // 0..10 (fractional on the wire) @@ -193,7 +215,52 @@ public readonly record struct WeenieData( int? ContainersCapacity, int? Structure, int? MaxStructure, - float? Workmanship); + float? Workmanship, + uint? Useability = null, + uint? TargetType = null); + +/// +/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0). +/// Low 16 bits describe where the source may be used from; high 16 bits +/// describe where the acquired target may be. +/// +public static class ItemUseability +{ + public const uint Undef = 0x0u; + public const uint No = 0x1u; + public const uint Self = 0x2u; + public const uint Wielded = 0x4u; + public const uint Contained = 0x8u; + public const uint Viewed = 0x10u; + public const uint Remote = 0x20u; + public const uint NeverWalk = 0x40u; + public const uint ObjSelf = 0x80u; + + public const uint SourceMask = 0x0000FFFFu; + public const uint TargetMask = 0xFFFF0000u; + + public static bool IsTargeted(uint useability) + => (useability & TargetMask) != 0; + + public static bool AllowsSelfTarget(uint useability) + => ((useability >> 16) & Self) != 0; + + public static bool AllowsObjectSelfTarget(uint useability) + => ((useability >> 16) & ObjSelf) != 0; + + public static uint SourceFlags(uint useability) + => useability & SourceMask; + + public static uint TargetFlags(uint useability) + => (useability & TargetMask) >> 16; + + public static bool IsDirectUseable(uint useability) + { + uint source = SourceFlags(useability); + return !IsTargeted(useability) + && (source & ~(No | NeverWalk)) != Undef; + } +} /// /// Container = inventory pack. Hierarchy is strictly 2-deep: character diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index a80b0fb2..9ab253e2 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -4,6 +4,12 @@ using System.Collections.Generic; namespace AcDream.Core.Items; +/// +/// Ordered container snapshot entry from retail ContentProfile: +/// guid plus m_uContainerProperties. +/// +public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType); + /// /// The client's table of every server object (retail weenie_object_table / /// CObjectMaint). Resolve by guid via Get. @@ -69,6 +75,7 @@ public sealed class ClientObjectTable /// the typed mirror maintains on /// . public const uint UiEffectsPropertyId = 18u; + public const uint CurrentWieldedLocationPropertyId = 10u; public int ObjectCount => _objects.Count; public int ContainerCount => _containers.Count; @@ -124,7 +131,7 @@ public sealed class ClientObjectTable /// so RollbackMove restores full pre-move state through this method alone. /// public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1, - EquipMask newEquipLocation = EquipMask.None) + EquipMask newEquipLocation = EquipMask.None, uint? containerTypeHint = null) { if (!_objects.TryGetValue(itemId, out var item)) return false; @@ -132,6 +139,7 @@ public sealed class ClientObjectTable item.ContainerId = newContainerId; item.ContainerSlot = newSlot; item.CurrentlyEquippedLocation = newEquipLocation; + if (containerTypeHint is { } hint) item.ContainerTypeHint = hint; Reindex(item, oldContainer); ObjectMoved?.Invoke(item, oldContainer, newContainerId); return true; @@ -306,6 +314,23 @@ public sealed class ClientObjectTable if (!_objects.TryGetValue(itemId, out var item)) return false; item.Properties.Ints[propertyId] = value; if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value; + if (propertyId == CurrentWieldedLocationPropertyId) + item.CurrentlyEquippedLocation = (EquipMask)(uint)value; + ObjectUpdated?.Invoke(item); + return true; + } + + /// + /// Apply a single PropertyInt64 update to an object's bundle and fire + /// ObjectUpdated so bound widgets refresh. Int64 counterpart of + /// (used by the character sheet's + /// optimistic unassigned-XP debit; also the hook for future + /// PrivateUpdatePropertyInt64 parsing). False if the object is unknown. + /// + public bool UpdateInt64Property(uint itemId, uint propertyId, long value) + { + if (!_objects.TryGetValue(itemId, out var item)) return false; + item.Properties.Int64s[propertyId] = value; ObjectUpdated?.Invoke(item); return true; } @@ -359,6 +384,8 @@ public sealed class ClientObjectTable if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl; if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl; if (d.Priority is { } pr) obj.Priority = pr; + if (d.Useability is { } use) obj.Useability = use; + if (d.TargetType is { } targetType) obj.TargetType = targetType; if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic; if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc; if (d.Structure is { } st) obj.Structure = st; @@ -377,7 +404,7 @@ public sealed class ClientObjectTable /// icon/name/type/effects — that data comes from CreateObject. /// public ClientObject RecordMembership(uint guid, uint containerId = 0, - EquipMask equip = EquipMask.None) + EquipMask equip = EquipMask.None, uint? containerTypeHint = null) { bool existed = _objects.TryGetValue(guid, out var obj); if (!existed || obj is null) // keep: satisfies nullable flow analysis @@ -386,8 +413,16 @@ public sealed class ClientObjectTable _objects[guid] = obj; } uint oldContainer = obj.ContainerId; - if (containerId != 0) obj.ContainerId = containerId; - if (equip != EquipMask.None) obj.CurrentlyEquippedLocation = equip; + if (containerId != 0) + { + obj.ContainerId = containerId; + obj.CurrentlyEquippedLocation = EquipMask.None; + } + else + { + obj.CurrentlyEquippedLocation = equip; + } + if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint; Reindex(obj, oldContainer); if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); return obj; @@ -404,12 +439,21 @@ public sealed class ClientObjectTable if (!_containerIndex.TryGetValue(obj.ContainerId, out var list)) _containerIndex[obj.ContainerId] = list = new List(); if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId); - list.Sort((a, b) => SlotOf(a).CompareTo(SlotOf(b))); + var priorOrder = new Dictionary(list.Count); + for (int i = 0; i < list.Count; i++) + priorOrder[list[i]] = i; + list.Sort((a, b) => + { + int c = SlotOf(a).CompareTo(SlotOf(b)); + return c != 0 ? c : priorOrder[a].CompareTo(priorOrder[b]); + }); } } private int SlotOf(uint guid) => - _objects.TryGetValue(guid, out var o) ? o.ContainerSlot : int.MaxValue; + _objects.TryGetValue(guid, out var o) && o.ContainerSlot >= 0 + ? o.ContainerSlot + : int.MaxValue; /// /// Ordered item guids in a container (retail object_inventory_table), by ContainerSlot. @@ -433,7 +477,26 @@ public sealed class ClientObjectTable ArgumentNullException.ThrowIfNull(guids); if (containerId == 0) return; - var keep = new HashSet(guids); + var entries = new ContainerContentEntry[guids.Count]; + for (int i = 0; i < guids.Count; i++) + entries[i] = new ContainerContentEntry(guids[i], 0u); + ReplaceContents(containerId, entries); + } + + /// + /// Replace a container's entire membership with in retail order. + /// PlayerDescription and ViewContents both carry ContentProfile entries; the order is the + /// dense list order retail feeds to ACCObjectMaint::ViewObjectContents, while ContainerType + /// chooses the loose-item list versus the side-pack/container list. + /// + public void ReplaceContents(uint containerId, IReadOnlyList entries) + { + ArgumentNullException.ThrowIfNull(entries); + if (containerId == 0) return; + + var keep = new HashSet(); + foreach (var entry in entries) + keep.Add(entry.Guid); // Detach prior members no longer present (they left the container server-side). GetContents // returns a snapshot, so mutating the index inside the loop is safe. @@ -442,23 +505,26 @@ public sealed class ClientObjectTable if (keep.Contains(old)) continue; if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue; o.ContainerId = 0; + o.CurrentlyEquippedLocation = EquipMask.None; Reindex(o, containerId); ObjectMoved?.Invoke(o, containerId, 0); } // Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition. - for (int i = 0; i < guids.Count; i++) + for (int i = 0; i < entries.Count; i++) { - uint g = guids[i]; - bool existed = _objects.TryGetValue(g, out var obj); + var entry = entries[i]; + bool existed = _objects.TryGetValue(entry.Guid, out var obj); if (!existed || obj is null) { - obj = new ClientObject { ObjectId = g }; - _objects[g] = obj; + obj = new ClientObject { ObjectId = entry.Guid }; + _objects[entry.Guid] = obj; } uint oldContainer = obj.ContainerId; obj.ContainerId = containerId; obj.ContainerSlot = i; + obj.CurrentlyEquippedLocation = EquipMask.None; + obj.ContainerTypeHint = entry.ContainerType; Reindex(obj, oldContainer); if (!existed) ObjectAdded?.Invoke(obj); else ObjectMoved?.Invoke(obj, oldContainer, containerId); diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs index f0b851b0..b5fa35db 100644 --- a/src/AcDream.Core/Player/LocalPlayerState.cs +++ b/src/AcDream.Core/Player/LocalPlayerState.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using AcDream.Core.Items; using AcDream.Core.Spells; namespace AcDream.Core.Player; @@ -87,10 +88,27 @@ public sealed class LocalPlayerState /// on primary-attribute state held elsewhere on the cache. public readonly record struct VitalSnapshot(uint Ranks, uint Start, uint Xp, uint Current); + /// Per-skill snapshot from PlayerDescription's CreatureSkill table. + public readonly record struct SkillSnapshot( + uint SkillId, + uint Ranks, + uint Status, + uint Xp, + uint Init, + uint Resistance, + double LastUsed, + uint FormulaBonus) + { + public uint BaseLevel => FormulaBonus + Init + Ranks; + public uint CurrentLevel => BaseLevel; + } + private VitalSnapshot? _health; private VitalSnapshot? _stamina; private VitalSnapshot? _mana; private readonly Dictionary _attrs = new(); + private readonly Dictionary _skills = new(); + private PropertyBundle _properties = new(); private readonly Spellbook? _spellbook; /// @@ -112,6 +130,9 @@ public sealed class LocalPlayerState /// only at PlayerDescription / future PrivateUpdateAttribute). public event System.Action? AttributeChanged; + /// Fires after player properties or skills from PlayerDescription change. + public event System.Action? CharacterChanged; + /// /// Map a vital-id (across both ID systems) to a . /// @@ -158,6 +179,16 @@ public sealed class LocalPlayerState public AttributeSnapshot? GetAttribute(AttributeKind kind) => _attrs.TryGetValue(kind, out var a) ? a : null; + /// Snapshot of the local player's current property bundle. + public PropertyBundle Properties => _properties; + + /// All known skill snapshots, keyed by SkillId. + public IReadOnlyDictionary Skills => _skills; + + /// Snapshot for one skill, or null if it has not arrived yet. + public SkillSnapshot? GetSkill(uint skillId) => + _skills.TryGetValue(skillId, out var s) ? s : null; + /// /// Compute the buffed max for a vital, using the full retail formula: /// (vital.(ranks+start) + attribute_contribution) × multiplier_buff + additive_buff @@ -273,6 +304,134 @@ public sealed class LocalPlayerState AttributeChanged?.Invoke(kind); } + /// Replace the local player's top-level property snapshot from PlayerDescription. + public void OnProperties(PropertyBundle properties) + { + _properties = properties.Clone(); + CharacterChanged?.Invoke(); + } + + /// Apply or replace one PlayerDescription skill entry. + public void OnSkillUpdate( + uint skillId, + uint ranks, + uint status, + uint xp, + uint init, + uint resistance, + double lastUsed, + uint formulaBonus) + { + _skills[skillId] = new SkillSnapshot( + skillId, ranks, status, xp, init, resistance, lastUsed, formulaBonus); + CharacterChanged?.Invoke(); + } + + /// + /// Optimistically apply a successful local attribute-raise action. + /// The next server snapshot remains authoritative; this keeps UI state current + /// during the round trip after sending the retail raise action. + /// + public bool ApplyAttributeRaise(uint atType, uint amount, ulong xpSpent) + { + if (AttributeIdToKind(atType) is not AttributeKind kind) return false; + if (!_attrs.TryGetValue(kind, out var prev)) return false; + + _attrs[kind] = prev with + { + Ranks = SaturatingAdd(prev.Ranks, amount), + Xp = SaturatingAdd(prev.Xp, xpSpent), + }; + AttributeChanged?.Invoke(kind); + return true; + } + + /// Optimistically apply a successful local max-vital raise action. + public bool ApplyVitalRaise(uint vitalId, uint amount, ulong xpSpent) + { + if (VitalIdToKind(vitalId) is not VitalKind kind) return false; + VitalSnapshot? existing = Get(kind); + if (existing is not VitalSnapshot prev) return false; + + var snap = prev with + { + Ranks = SaturatingAdd(prev.Ranks, amount), + Xp = SaturatingAdd(prev.Xp, xpSpent), + }; + switch (kind) + { + case VitalKind.Health: _health = snap; break; + case VitalKind.Stamina: _stamina = snap; break; + case VitalKind.Mana: _mana = snap; break; + } + Changed?.Invoke(kind); + return true; + } + + /// Optimistically promote an untrained skill after a successful TrainSkill action. + public bool ApplySkillTraining(uint skillId) + { + if (!_skills.TryGetValue(skillId, out var prev)) return false; + if (prev.Status >= 2u) return false; + + _skills[skillId] = prev with { Status = 2u }; + CharacterChanged?.Invoke(); + return true; + } + + /// Optimistically apply a successful local skill-raise action. + public bool ApplySkillRaise(uint skillId, uint amount, ulong xpSpent) + { + if (!_skills.TryGetValue(skillId, out var prev)) return false; + if (prev.Status < 2u) return false; + + _skills[skillId] = prev with + { + Ranks = SaturatingAdd(prev.Ranks, amount), + Xp = SaturatingAdd(prev.Xp, xpSpent), + }; + CharacterChanged?.Invoke(); + return true; + } + + /// + /// Optimistically debit an int property in the player's property bundle + /// (clamped at 0), firing so bound UI + /// refreshes. False if the property is absent — callers walk their + /// fallback id chain. The next server snapshot remains authoritative. + /// All local-player property writes go through eventful APIs like this + /// one; writing dictionaries directly skips the + /// change event and is a single-owner-state violation. + /// + public bool DebitIntProperty(uint propertyId, int amount) + { + if (!_properties.Ints.TryGetValue(propertyId, out int current)) + return false; + _properties.Ints[propertyId] = current > amount ? current - amount : 0; + CharacterChanged?.Invoke(); + return true; + } + + /// + /// Optimistically debit an int64 property (clamped at 0), firing + /// . False if the property is absent or + /// already ≤ 0. The next server snapshot remains authoritative. + /// + public bool DebitInt64Property(uint propertyId, long amount) + { + if (!_properties.Int64s.TryGetValue(propertyId, out long current) || current <= 0) + return false; + _properties.Int64s[propertyId] = current > amount ? current - amount : 0L; + CharacterChanged?.Invoke(); + return true; + } + + private static uint SaturatingAdd(uint value, uint delta) + => uint.MaxValue - value < delta ? uint.MaxValue : value + delta; + + private static uint SaturatingAdd(uint value, ulong delta) + => delta > uint.MaxValue - value ? uint.MaxValue : value + (uint)delta; + // ── Retail attribute contribution ────────────────────────────────────── // // Source: ACE Source/ACE.Server/Entity/AttributeFormula.cs + diff --git a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs index b18590ae..9d9be640 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs @@ -25,4 +25,20 @@ public class RuntimeOptionsRetailUiTests Assert.False(opts.RetailUi); Assert.Null(opts.AcDir); } + + [Fact] + public void Parse_ReadsUiProbeOptions() + { + var env = new Dictionary + { + ["ACDREAM_UI_PROBE_DUMP"] = "1", + ["ACDREAM_UI_PROBE_SCRIPT"] = @"C:\tmp\ui-probe.txt", + }; + + var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); + + Assert.True(opts.UiProbeDump); + Assert.Equal(@"C:\tmp\ui-probe.txt", opts.UiProbeScript); + Assert.True(opts.UiProbeEnabled); + } } diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index a6e0619e..02fc8aca 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -38,6 +38,9 @@ public sealed class RuntimeOptionsTests Assert.True(opts.RetailCloseDegrades); Assert.False(opts.DumpSceneryZ); Assert.Null(opts.LegacyStreamRadius); + Assert.False(opts.UiProbeDump); + Assert.Null(opts.UiProbeScript); + Assert.False(opts.UiProbeEnabled); Assert.False(opts.HasLiveCredentials); } diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs new file mode 100644 index 00000000..b3bc6b85 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs @@ -0,0 +1,155 @@ +using AcDream.App.UI; +using AcDream.Core.Items; + +namespace AcDream.App.Tests.UI; + +public sealed class CursorFeedbackControllerTests +{ + private const uint Player = 0x50000001u; + private const uint Pack = 0x50000010u; + private const uint Source = 0x50000020u; + private const uint Target = 0x50000021u; + private const uint HealthKitUseability = 0x000A0008u; + + [Fact] + public void DragState_winsOverResizeAndUsesAcceptRejectCursors() + { + var c = new CursorFeedbackController(); + + var accept = c.Resolve(new CursorFeedbackSnapshot( + DragPayload: new object(), + DragAccept: UiItemSlot.DragAcceptState.Accept, + HoverResizeEdges: ResizeEdges.Right)); + var reject = c.Resolve(new CursorFeedbackSnapshot( + DragPayload: new object(), + DragAccept: UiItemSlot.DragAcceptState.Reject, + HoverResizeEdges: ResizeEdges.Right)); + + Assert.Equal(CursorFeedbackKind.DragAccept, accept.Kind); + Assert.Equal(CursorFeedbackKind.DragReject, reject.Kind); + } + + [Theory] + [InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal)] + [InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical)] + [InlineData(ResizeEdges.Right | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNwse)] + [InlineData(ResizeEdges.Left | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNesw)] + public void ResizeEdges_chooseExpectedCursor(ResizeEdges edges, CursorFeedbackKind expected) + { + var c = new CursorFeedbackController(); + + var feedback = c.Resolve(new CursorFeedbackSnapshot(HoverResizeEdges: edges)); + + Assert.Equal(expected, feedback.Kind); + } + + [Fact] + public void MoveAndTextHover_useDedicatedCursorsWhenNoHigherPriorityState() + { + var c = new CursorFeedbackController(); + + Assert.Equal(CursorFeedbackKind.WindowMove, + c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Kind); + Assert.Equal(CursorFeedbackKind.Text, + c.Resolve(new CursorFeedbackSnapshot(HoverTextEdit: true)).Kind); + } + + [Fact] + public void TargetMode_reportsValidInvalidAndPendingTargets() + { + var objects = SeedTargetObjects(); + var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + nowMs: () => 1_000); + var c = new CursorFeedbackController(interaction); + + Assert.True(interaction.ActivateItem(Source)); + + Assert.Equal(CursorFeedbackKind.TargetValid, + c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: Player)).Kind); + Assert.Equal(CursorFeedbackKind.TargetInvalid, + c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: 0x5000BADu)).Kind); + Assert.Equal(CursorFeedbackKind.TargetInvalid, + c.Resolve(new CursorFeedbackSnapshot(HoverUi: true)).Kind); + Assert.Equal(CursorFeedbackKind.TargetPending, + c.Resolve(new CursorFeedbackSnapshot()).Kind); + } + + [Fact] + public void UpdateFromRoot_usesUseTargetGuidProviderBeforeSlotItem() + { + var objects = SeedTargetObjects(); + var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + nowMs: () => 1_000); + var root = new UiRoot { Width = 800, Height = 600 }; + var slot = new UiItemSlot + { + Left = 10, + Top = 10, + Width = 32, + Height = 32, + UseTargetGuidProvider = () => Player, + }; + var acceptCursor = new UiCursorMedia(0x06009999u, 11, 12); + slot.SetStateCursors(new Dictionary + { + ["Drag_rollover_accept"] = acceptCursor, + }); + slot.SetItem(Target, iconTexture: 1u); + root.AddChild(slot); + root.OnMouseMove(20, 20); + var c = new CursorFeedbackController(interaction); + + Assert.True(interaction.ActivateItem(Source)); + + var feedback = c.Update(root); + Assert.Equal(CursorFeedbackKind.TargetValid, feedback.Kind); + Assert.Equal(acceptCursor, feedback.Cursor); + } + + private static ClientObjectTable SeedTargetObjects() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Name = "Player", + Type = ItemType.Creature, + }); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Pack, + Name = "Backpack", + Type = ItemType.Container, + ItemsCapacity = 24, + }); + objects.MoveItem(Pack, Player, 0); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Source, + Name = "Health Kit", + Type = ItemType.Misc, + Useability = HealthKitUseability, + }); + objects.MoveItem(Source, Pack, 0); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Target, + Name = "Target", + Type = ItemType.Misc, + }); + objects.MoveItem(Target, Pack, 1); + return objects; + } +} diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs new file mode 100644 index 00000000..926ef73c --- /dev/null +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -0,0 +1,303 @@ +using AcDream.App.UI; +using AcDream.Core.Items; + +namespace AcDream.App.Tests.UI; + +public sealed class ItemInteractionControllerTests +{ + private const uint Player = 0x50000001u; + private const uint Pack = 0x50000010u; + private const uint HealthKitUseability = 0x000A0008u; + + private sealed class Harness + { + public readonly ClientObjectTable Objects = new(); + public readonly List Uses = new(); + public readonly List<(uint Source, uint Target)> UseWithTarget = new(); + public readonly List<(uint Item, uint Mask)> Wields = new(); + public readonly List Drops = new(); + public readonly List Toasts = new(); + public long Now = 1_000; + + public Harness() + { + Objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Name = "Player", + Type = ItemType.Creature, + }); + Objects.AddOrUpdate(new ClientObject + { + ObjectId = Pack, + Name = "Backpack", + Type = ItemType.Container, + ItemsCapacity = 24, + }); + Objects.MoveItem(Pack, Player, 0); + + Controller = new ItemInteractionController( + Objects, + playerGuid: () => Player, + sendUse: Uses.Add, + sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)), + sendWield: (item, mask) => Wields.Add((item, mask)), + sendDrop: Drops.Add, + nowMs: () => Now, + toast: Toasts.Add); + } + + public ItemInteractionController Controller { get; } + + public ClientObject AddContained(uint id, Action? configure = null) + { + var item = new ClientObject + { + ObjectId = id, + Name = $"Item {id:X}", + Type = ItemType.Misc, + IconId = 0x06001234u, + }; + configure?.Invoke(item); + Objects.AddOrUpdate(item); + Objects.MoveItem(id, Pack, Objects.GetContents(Pack).Count); + return item; + } + } + + [Fact] + public void TargetedItem_entersTargetModeAndMarksPendingSource() + { + var h = new Harness(); + h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability); + + Assert.True(h.Controller.ActivateItem(0x50000A01u)); + + Assert.True(h.Controller.IsTargetModeActive); + Assert.True(h.Controller.IsPendingSource(0x50000A01u)); + Assert.Empty(h.UseWithTarget); + } + + [Fact] + public void SelfTarget_sendsUseWithTargetAndClearsTargetMode() + { + var h = new Harness(); + h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability); + + h.Controller.ActivateItem(0x50000A01u); + Assert.True(h.Controller.AcquireSelfTarget()); + + Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget); + Assert.False(h.Controller.IsTargetModeActive); + } + + [Fact] + public void ActivateTargetItemDuringTargetMode_usesClickedItemAsTarget() + { + var h = new Harness(); + h.AddContained(0x50000A01u, item => item.Useability = 0x00080008u); + h.AddContained(0x50000A02u); + + h.Controller.ActivateItem(0x50000A01u); + Assert.True(h.Controller.ActivateItem(0x50000A02u)); + + Assert.Equal(new[] { (0x50000A01u, 0x50000A02u) }, h.UseWithTarget); + Assert.False(h.Controller.IsTargetModeActive); + } + + [Fact] + public void DirectUseItem_sendsUse() + { + var h = new Harness(); + h.AddContained(0x50000A03u, item => item.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.ActivateItem(0x50000A03u)); + + Assert.Equal(new[] { 0x50000A03u }, h.Uses); + } + + [Fact] + public void ContainerItem_sendsUseToOpen() + { + var h = new Harness(); + h.AddContained(0x50000A04u, item => + { + item.Type = ItemType.Container; + item.ItemsCapacity = 12; + }); + + Assert.True(h.Controller.ActivateItem(0x50000A04u)); + + Assert.Equal(new[] { 0x50000A04u }, h.Uses); + } + + [Fact] + public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically() + { + var h = new Harness(); + h.AddContained(0x50000A05u, item => + { + item.Type = ItemType.Clothing; + item.ValidLocations = EquipMask.HeadWear; + }); + + Assert.True(h.Controller.ActivateItem(0x50000A05u)); + + Assert.Equal(new[] { (0x50000A05u, (uint)EquipMask.HeadWear) }, h.Wields); + var equipped = h.Objects.Get(0x50000A05u)!; + Assert.Equal(Player, equipped.ContainerId); + Assert.Equal(EquipMask.HeadWear, equipped.CurrentlyEquippedLocation); + } + + [Fact] + public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically() + { + var h = new Harness(); + const EquipMask coatMask = + EquipMask.ChestWear + | EquipMask.UpperArmWear + | EquipMask.LowerArmWear; + h.AddContained(0x50000A15u, item => + { + item.Type = ItemType.Clothing; + item.ValidLocations = coatMask; + }); + + Assert.True(h.Controller.ActivateItem(0x50000A15u)); + + Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields); + var equipped = h.Objects.Get(0x50000A15u)!; + Assert.Equal(Player, equipped.ContainerId); + Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation); + } + + [Fact] + public void AutoWearItemWithOverlappingSlotButDifferentPriority_sendsFullMask() + { + var h = new Harness(); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = 0x50000AF1u, + Type = ItemType.Clothing, + CurrentlyEquippedLocation = EquipMask.UpperArmWear, + Priority = 0x00000001u, + }); + h.Objects.MoveItem(0x50000AF1u, Player, -1, EquipMask.UpperArmWear); + const EquipMask coatMask = + EquipMask.ChestWear + | EquipMask.UpperArmWear + | EquipMask.LowerArmWear; + h.AddContained(0x50000A16u, item => + { + item.Type = ItemType.Clothing; + item.ValidLocations = coatMask; + item.Priority = 0x00000002u; + }); + + Assert.True(h.Controller.ActivateItem(0x50000A16u)); + + Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields); + Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation); + } + + [Fact] + public void AutoWearItemWithOverlappingSlotAndPriority_sendsNothing() + { + var h = new Harness(); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = 0x50000AF2u, + Type = ItemType.Clothing, + CurrentlyEquippedLocation = EquipMask.UpperArmWear, + Priority = 0x00000004u, + }); + h.Objects.MoveItem(0x50000AF2u, Player, -1, EquipMask.UpperArmWear); + const EquipMask coatMask = + EquipMask.ChestWear + | EquipMask.UpperArmWear + | EquipMask.LowerArmWear; + h.AddContained(0x50000A17u, item => + { + item.Type = ItemType.Clothing; + item.ValidLocations = coatMask; + item.Priority = 0x00000004u; + }); + + Assert.False(h.Controller.ActivateItem(0x50000A17u)); + + Assert.Empty(h.Wields); + Assert.Equal(Pack, h.Objects.Get(0x50000A17u)!.ContainerId); + } + + [Fact] + public void EquippableItemWithNoFreeSlot_sendsNothing() + { + var h = new Harness(); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = 0x50000AF0u, + Type = ItemType.Armor, + CurrentlyEquippedLocation = EquipMask.Shield, + }); + h.Objects.MoveItem(0x50000AF0u, Player, -1, EquipMask.Shield); + h.AddContained(0x50000A06u, item => + { + item.Type = ItemType.Armor; + item.ValidLocations = EquipMask.Shield; + }); + + Assert.False(h.Controller.ActivateItem(0x50000A06u)); + + Assert.Empty(h.Wields); + Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId); + } + + [Fact] + public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically() + { + var h = new Harness(); + h.AddContained(0x50000A07u); + var payload = new ItemDragPayload( + 0x50000A07u, + ItemDragSource.Inventory, + SourceSlot: 0, + SourceCell: new UiItemSlot()); + + Assert.True(h.Controller.DropToWorld(payload)); + + Assert.Equal(new[] { 0x50000A07u }, h.Drops); + Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId); + } + + [Fact] + public void ToolbarShortcutDragOutsideUi_doesNotDropRealItem() + { + var h = new Harness(); + h.AddContained(0x50000A08u); + var payload = new ItemDragPayload( + 0x50000A08u, + ItemDragSource.ShortcutBar, + SourceSlot: 0, + SourceCell: new UiItemSlot()); + + Assert.False(h.Controller.DropToWorld(payload)); + + Assert.Empty(h.Drops); + Assert.Equal(Pack, h.Objects.Get(0x50000A08u)!.ContainerId); + } + + [Fact] + public void ActivateItem_appliesRetailUseThrottle() + { + var h = new Harness(); + h.AddContained(0x50000A09u, item => item.Useability = ItemUseability.Contained); + + h.Controller.ActivateItem(0x50000A09u); + h.Now += 199; + h.Controller.ActivateItem(0x50000A09u); + h.Now += 1; + h.Controller.ActivateItem(0x50000A09u); + + Assert.Equal(new[] { 0x50000A09u, 0x50000A09u }, h.Uses); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs new file mode 100644 index 00000000..d2b334fb --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using AcDream.App.Studio; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class CharacterLayoutImportProbe +{ + private const uint CharacterLayout = 0x2100002Eu; + + private static string? DatDir() + { + var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(d) ? d : null; + } + + [Fact] + public void Selected_attribute_shows_raise_buttons_on_visible_footer_state() + { + var datDir = DatDir(); + if (datDir is null) return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var layout = LayoutImporter.Import(dats, CharacterLayout, _ => (1u, 30, 26), null); + Assert.NotNull(layout); + + CharacterStatController.Bind(layout!, SampleData.SampleCharacter, spriteResolve: _ => (1u, 30, 26)); + + var rows = new List(); + CollectRows(layout!.Root, rows); + Assert.True(rows.Count >= 9, $"expected at least 9 attribute rows, found {rows.Count}"); + + rows[4].OnClick!(); + + var buttons = new List(); + CollectButtons(layout.Root, buttons); + + Assert.Contains(buttons, b => + b.ElementId == CharacterStatController.RaiseOneId + && b.ActiveState == "Normal" + && IsEffectivelyVisible(b)); + Assert.Contains(buttons, b => + b.ElementId == CharacterStatController.RaiseTenId + && b.ActiveState == "Normal" + && IsEffectivelyVisible(b)); + } + + [Fact] + public void Close_button_resolves_and_invokes_controller_close_callback() + { + var datDir = DatDir(); + if (datDir is null) return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var layout = LayoutImporter.Import(dats, CharacterLayout, _ => (1u, 30, 26), null); + Assert.NotNull(layout); + + var close = layout!.FindElement(WindowChromeController.CharacterCloseButtonId); + Assert.NotNull(close); + Assert.True(close is UiButton or UiDatElement, + $"character close button resolved to {close?.GetType().Name ?? "null"}"); + + int closes = 0; + CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++); + close!.OnEvent(new UiEvent(0u, close, UiEventType.Click)); + + Assert.Equal(1, closes); + } + + private static void CollectRows(UiElement node, List result) + { + if (node is UiClickablePanel row && row.Height is >= 20f and <= 22f && row.OnClick is not null) + result.Add(row); + foreach (var child in node.Children) + CollectRows(child, result); + } + + private static void CollectButtons(UiElement node, List result) + { + if (node is UiButton button + && (button.ElementId == CharacterStatController.RaiseOneId + || button.ElementId == CharacterStatController.RaiseTenId)) + { + result.Add(button); + } + + foreach (var child in node.Children) + CollectButtons(child, result); + } + + private static bool IsEffectivelyVisible(UiElement element) + { + for (UiElement? e = element; e is not null; e = e.Parent) + { + if (!e.Visible) return false; + } + return true; + } + +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs new file mode 100644 index 00000000..a6b18b26 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Linq; +using AcDream.App.UI.Layout; +using AcDream.Core.Items; +using AcDream.Core.Player; +using Xunit; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Tests for — the extracted character-sheet +/// assembly + raise flow (formerly private methods inside GameWindow). Covers the +/// XP-curve math against a known synthetic ExperienceTable and the single-owner +/// spend routing (table debits fire ObjectUpdated; LocalPlayerState debits fire +/// CharacterChanged). +/// +public sealed class CharacterSheetProviderTests +{ + private const uint PlayerGuid = 0x50000001u; + + /// Synthetic cumulative-XP curves. Levels band 1→2 spans 100..250. + private static DatReaderWriter.DBObjs.ExperienceTable MakeXpTable() => new() + { + Levels = new ulong[] { 0, 100, 250, 450 }, + Attributes = new uint[] { 0, 10, 30, 60, 100 }, + Vitals = new uint[] { 0, 4, 12, 24 }, + TrainedSkills = new uint[] { 0, 5, 15, 30 }, + SpecializedSkills = new uint[] { 0, 8, 24, 48 }, + }; + + private sealed class Harness + { + public ClientObjectTable Table { get; } = new(); + public LocalPlayerState Player { get; } = new(); + public CharacterSheetProvider Provider { get; } + public (uint statId, ulong cost)? SentAttribute; + public (uint skillId, uint credits)? SentTrain; + public bool CanSend = true; + + public Harness() + { + Provider = new CharacterSheetProvider( + Table, Player, + playerGuid: () => PlayerGuid, + activeToonName: () => "default", + fallbackSheet: name => new CharacterSheet { Name = name, Level = -1 }, + canSendRaise: () => CanSend, + sendRaiseAttribute: (statId, cost) => SentAttribute = (statId, cost), + sendRaiseVital: (_, _) => { }, + sendRaiseSkill: (_, _) => { }, + sendTrainSkill: (skillId, credits) => SentTrain = (skillId, credits)) + { + ExperienceTable = MakeXpTable(), + }; + } + + /// Put the player's ClientObject in the table with live sheet properties. + public ClientObject AddPlayerObject(long unassignedXp = 1000L) + { + var player = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; + player.Properties.Ints[0x19u] = 1; // level + player.Properties.Int64s[1u] = 150L; // total XP — mid 100..250 band + player.Properties.Int64s[2u] = unassignedXp; + Table.AddOrUpdate(player); + return player; + } + } + + [Fact] + public void BuildSheet_NoLiveData_UsesFallbackSheet() + { + var h = new Harness(); + + var sheet = h.Provider.BuildSheet(); + + Assert.Equal(-1, sheet.Level); // fallback marker + Assert.Equal("Player", sheet.Name); // toon key "default" + no object → "Player" + } + + [Fact] + public void BuildSheet_LiveData_ComputesLevelBandAndRaiseCosts() + { + var h = new Harness(); + h.AddPlayerObject(unassignedXp: 777L); + h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); // Strength + + var sheet = h.Provider.BuildSheet(); + + Assert.Equal("Testy", sheet.Name); // live object name beats "Player" + Assert.Equal(1, sheet.Level); + Assert.Equal(150L, sheet.TotalXp); + Assert.Equal(777L, sheet.UnassignedXp); + // Level band 100..250, at 150: 100 XP to next, 1/3 through the band. + Assert.Equal(100L, sheet.XpToNextLevel); + Assert.Equal(1f / 3f, sheet.XpFraction, precision: 4); + Assert.Equal(11, sheet.Strength); // ranks + start + // Raise x1: Attributes[2] − xpSpent = 30 − 10; x10 clamps at curve end: 100 − 10. + Assert.Equal(20L, sheet.AttributeRaiseCosts[0]); + Assert.Equal(90L, sheet.AttributeRaise10Costs[0]); + } + + [Fact] + public void BuildSheet_Skills_MapsAdvancementAndCurveCosts() + { + var h = new Harness(); + h.AddPlayerObject(); + h.Player.OnSkillUpdate(skillId: 6u, ranks: 1u, status: 2u, xp: 5u, + init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // trained + h.Player.OnSkillUpdate(skillId: 7u, ranks: 0u, status: 0u, xp: 0u, + init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // inactive → excluded + + var sheet = h.Provider.BuildSheet(); + + var skill = Assert.Single(sheet.Skills); + Assert.Equal(6u, skill.Id); + Assert.Equal("Skill 6", skill.Name); // no SkillTable → id fallback name + Assert.Equal(CharacterSkillAdvancementClass.Trained, skill.AdvancementClass); + // TrainedSkills curve: x1 = 15 − 5; x10 clamps at index 3: 30 − 5. + Assert.Equal(10L, skill.RaiseCost); + Assert.Equal(25L, skill.Raise10Cost); + } + + [Fact] + public void HandleRaiseRequest_Attribute_SendsAndDebitsThroughTableEvents() + { + var h = new Harness(); + h.AddPlayerObject(unassignedXp: 1000L); + h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); + int tableUpdates = 0; + h.Table.ObjectUpdated += _ => tableUpdates++; + + h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( + CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1)); + + Assert.Equal((1u, 20ul), h.SentAttribute); + var strength = h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength); + Assert.Equal(2u, strength!.Value.Ranks); // optimistic rank apply + Assert.Equal(980L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); // XP debited + Assert.True(tableUpdates >= 1); // via the eventful API + } + + [Fact] + public void HandleRaiseRequest_Blocked_WhenCanSendIsFalse() + { + var h = new Harness(); + h.AddPlayerObject(unassignedXp: 1000L); + h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); + h.CanSend = false; + + h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( + CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1)); + + Assert.Null(h.SentAttribute); + Assert.Equal(1u, h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength)!.Value.Ranks); + Assert.Equal(1000L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); + } + + [Fact] + public void HandleRaiseRequest_TrainSkill_DebitsFirstPresentCreditProperty() + { + var h = new Harness(); + var player = h.AddPlayerObject(); + player.Properties.Ints[0xC0u] = 4; // only the second id in the 0x18→0xC0→0xB5 chain + h.Player.OnSkillUpdate(skillId: 6u, ranks: 0u, status: 1u, xp: 0u, + init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // untrained + + h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( + CharacterStatController.RaiseTargetKind.TrainSkill, StatId: 6u, Cost: 4L, Amount: 1)); + + Assert.Equal((6u, 4u), h.SentTrain); + Assert.Equal(2u, h.Player.GetSkill(6u)!.Value.Status); // promoted to trained + Assert.Equal(0, player.Properties.GetInt(0xC0u)); // credits debited + } + + [Fact] + public void SpendUnassignedXp_FallsBackToLocalPlayer_WhenPlayerObjectAbsent() + { + var h = new Harness(); // note: nothing added to the table + var props = new PropertyBundle(); + props.Int64s[2u] = 500L; + h.Player.OnProperties(props); + h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); + int changed = 0; + h.Player.CharacterChanged += () => changed++; + + h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( + CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 100L, Amount: 1)); + + Assert.Equal(400L, h.Player.Properties.GetInt64(2u)); // debited on the LPS side + Assert.True(changed >= 1); // and CharacterChanged fired + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 309abb56..eb81ce7a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -50,10 +50,114 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - Assert.Equal("Aluvian Heritage", heritage.LinesProvider()[0].Text); + Assert.Equal("Female Aluvian Adventurer", heritage.LinesProvider()[0].Text); Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text); } + [Fact] + public void Bind_WindowChromeButton_InvokesCloseCallback() + { + var close = MakeButton(WindowChromeController.CharacterCloseButtonId); + var layout = Fake((WindowChromeController.CharacterCloseButtonId, close)); + int closes = 0; + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++); + close.OnEvent(new UiEvent(0u, close, UiEventType.Click)); + + Assert.Equal(1, closes); + } + + [Fact] + public void CharacterIdentityText_StatHeaderLine_ComposesRetailGenderHeritageTitle() + { + var sheet = new CharacterSheet + { + Gender = "Female", + Heritage = "Aluvian", + Title = "the Adventurer", + }; + + Assert.Equal("Female Aluvian Adventurer", CharacterIdentityText.StatHeaderLine(sheet)); + } + + [Theory] + [InlineData(1, "Male")] + [InlineData(2, "Female")] + [InlineData(0, null)] + public void CharacterIdentityText_GenderDisplayName_UsesRetailEnum(int value, string? expected) + => Assert.Equal(expected, CharacterIdentityText.GenderDisplayName(value)); + + [Theory] + [InlineData(1, "Aluvian")] + [InlineData(2, "Gharu'ndim")] + [InlineData(5, "Umbraen")] + [InlineData(13, "Olthoi")] + [InlineData(99, null)] + public void CharacterIdentityText_HeritageGroupDisplayName_UsesRetailEnum(int value, string? expected) + => Assert.Equal(expected, CharacterIdentityText.HeritageGroupDisplayName(value)); + + [Fact] + public void Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated() + { + var root = new UiPanel { Width = 300, Height = 600 }; + var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575); + var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575); + + var visibleName = new UiText { ElementId = CharacterStatController.NameId }; + var hiddenName = new UiText { ElementId = CharacterStatController.NameId }; + var visibleHeritage = new UiText { ElementId = CharacterStatController.HeritageId }; + var hiddenHeritage = new UiText { ElementId = CharacterStatController.HeritageId }; + var visibleLevel = new UiText { ElementId = CharacterStatController.LevelId }; + var hiddenLevel = new UiText { ElementId = CharacterStatController.LevelId }; + var visibleTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId }; + var hiddenTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId }; + var visibleTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId }; + var hiddenTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId }; + var visibleMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId }; + var hiddenMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId }; + var visibleXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId }; + var hiddenXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId }; + visibleMeter.AddChild(visibleXpNext); + hiddenMeter.AddChild(hiddenXpNext); + + attrPage.AddChild(visibleName); + attrPage.AddChild(visibleHeritage); + attrPage.AddChild(visibleLevel); + attrPage.AddChild(visibleTotalXpLabel); + attrPage.AddChild(visibleTotalXp); + attrPage.AddChild(visibleMeter); + hiddenPage.AddChild(hiddenName); + hiddenPage.AddChild(hiddenHeritage); + hiddenPage.AddChild(hiddenLevel); + hiddenPage.AddChild(hiddenTotalXpLabel); + hiddenPage.AddChild(hiddenTotalXp); + hiddenPage.AddChild(hiddenMeter); + root.AddChild(attrPage); + root.AddChild(hiddenPage); + + var layout = new ImportedLayout(root, new Dictionary + { + [CharacterStatController.NameId] = hiddenName, + [CharacterStatController.HeritageId] = hiddenHeritage, + [CharacterStatController.LevelId] = hiddenLevel, + [CharacterStatController.TotalXpLabelId] = hiddenTotalXpLabel, + [CharacterStatController.TotalXpId] = hiddenTotalXp, + [CharacterStatController.XpMeterId] = hiddenMeter, + [CharacterStatController.XpNextValueId] = hiddenXpNext, + }); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + Assert.Equal("Studio Player", visibleName.LinesProvider()[0].Text); + Assert.Equal("Female Aluvian Adventurer", visibleHeritage.LinesProvider()[0].Text); + Assert.Equal("126", visibleLevel.LinesProvider()[0].Text); + Assert.Equal("Total Experience (XP):", visibleTotalXpLabel.LinesProvider()[0].Text); + Assert.Equal((1_250_000_000L).ToString("N0"), visibleTotalXp.LinesProvider()[0].Text); + Assert.Equal((42_000_000L).ToString("N0"), visibleXpNext.LinesProvider()[0].Text); + Assert.Empty(hiddenName.LinesProvider()); + Assert.Empty(hiddenXpNext.LinesProvider()); + } + // ── XP meter fill ──────────────────────────────────────────────────────── [Fact] @@ -514,6 +618,31 @@ public class CharacterStatControllerTests Assert.Equal("Normal", btn10.ActiveState); } + [Fact] + public void RaiseButtons_OnlyOneAffordable_SplitsOneAndTenStates() + { + var btn1 = MakeButton(); + var btn10 = MakeButton(); + var list = new UiPanel(); + var layout = Fake( + (CharacterStatController.RaiseOneId, btn1), + (CharacterStatController.RaiseTenId, btn10), + (CharacterStatController.ListBoxId, list)); + var sheet = new CharacterSheet + { + UnassignedXp = 500L, + AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L }, + AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L }, + }; + + CharacterStatController.Bind(layout, () => sheet); + + list.Children.OfType().ToList()[4].OnClick!(); + + Assert.Equal("Normal", btn1.ActiveState); + Assert.Equal("Ghosted", btn10.ActiveState); + } + [Fact] public void RaiseButtons_MaxedRow_ShowsGhostedState() { @@ -535,6 +664,95 @@ public class CharacterStatControllerTests Assert.Equal("Ghosted", btn10.ActiveState); } + [Fact] + public void RaiseButtons_ClickAffordableAttribute_EmitsRaiseRequest() + { + var btn1 = MakeButton(); + var list = new UiPanel(); + var layout = Fake( + (CharacterStatController.RaiseOneId, btn1), + (CharacterStatController.ListBoxId, list)); + var requests = new List(); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add); + + list.Children.OfType().ToList()[4].OnClick!(); + btn1.OnClick!(); + + var request = Assert.Single(requests); + Assert.Equal(CharacterStatController.RaiseTargetKind.Attribute, request.Kind); + Assert.Equal(5u, request.StatId); + Assert.Equal(110L, request.Cost); + Assert.Equal(1, request.Amount); + } + + [Fact] + public void RaiseButtons_ClickAffordableAttribute_KeepsNormalStateUntilCostsRefresh() + { + var btn1 = MakeButton(); + var list = new UiPanel(); + var layout = Fake( + (CharacterStatController.RaiseOneId, btn1), + (CharacterStatController.ListBoxId, list)); + var requests = new List(); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add); + + list.Children.OfType().ToList()[4].OnClick!(); + Assert.Equal("Normal", btn1.ActiveState); + + btn1.OnClick!(); + + Assert.Single(requests); + Assert.Equal("Normal", btn1.ActiveState); + } + + [Fact] + public void RaiseButtons_ClickAffordableVital_EmitsMaxVitalId() + { + var btn1 = MakeButton(); + var list = new UiPanel(); + var layout = Fake( + (CharacterStatController.RaiseOneId, btn1), + (CharacterStatController.ListBoxId, list)); + var requests = new List(); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add); + + list.Children.OfType().ToList()[6].OnClick!(); + btn1.OnClick!(); + + var request = Assert.Single(requests); + Assert.Equal(CharacterStatController.RaiseTargetKind.Vital, request.Kind); + Assert.Equal(1u, request.StatId); + Assert.Equal(90L, request.Cost); + Assert.Equal(1, request.Amount); + } + + [Fact] + public void RaiseButtons_ClickUnaffordableTen_DoesNotEmitRequest() + { + var btn10 = MakeButton(); + var list = new UiPanel(); + var layout = Fake( + (CharacterStatController.RaiseTenId, btn10), + (CharacterStatController.ListBoxId, list)); + var requests = new List(); + var sheet = new CharacterSheet + { + UnassignedXp = 500L, + AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L }, + AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L }, + }; + + CharacterStatController.Bind(layout, () => sheet, onRaiseRequest: requests.Add); + + list.Children.OfType().ToList()[4].OnClick!(); + btn10.OnClick!(); + + Assert.Empty(requests); + } + [Fact] public void RaiseButtons_Deselect_HidesButtons() { @@ -635,10 +853,7 @@ public class CharacterStatControllerTests ClickTab(layout, left: 92f); - var headerPanels = list.Children - .Where(c => c is UiPanel and not UiClickablePanel) - .Cast() - .ToList(); + var headerPanels = SkillHeaders(list); var headers = headerPanels .Select(c => c.Children.OfType().First().LinesProvider()[0].Text) .ToList(); @@ -654,7 +869,7 @@ public class CharacterStatControllerTests Assert.All(headerPanels, h => Assert.Equal(Vector4.One, h.Children.OfType().First().LinesProvider()[0].Color)); - var rows = list.Children.OfType().ToList(); + var rows = SkillRows(list); Assert.Equal(12, rows.Count); Assert.All(rows, row => { @@ -692,7 +907,7 @@ public class CharacterStatControllerTests spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); - Assert.Equal(12, list.Children.OfType().Count()); + Assert.Equal(12, SkillRows(list).Count); ClickTab(layout, left: 0f); var rows = list.Children.OfType().ToList(); @@ -704,8 +919,8 @@ public class CharacterStatControllerTests public void SkillsTab_MouseClick_RebuildsVisibleListWhenListIdIsDuplicated() { var root = new UiPanel { Width = 300, Height = 600 }; - var attrPage = new UiPanel { Top = 25, Width = 300, Height = 575 }; - var hiddenPage = new UiPanel { Top = 25, Width = 300, Height = 575 }; + var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575); + var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575); var name = new UiText(); var visibleList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398); var hiddenDuplicateList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398); @@ -761,7 +976,7 @@ public class CharacterStatControllerTests spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); - var rows = list.Children.OfType().ToList(); + var rows = SkillRows(list); rows[1].OnClick!(); Assert.Equal("War Magic: 285", title.LinesProvider()[0].Text); @@ -777,6 +992,31 @@ public class CharacterStatControllerTests Assert.Equal(Vector4.Zero, rows[0].BackgroundColor); } + [Fact] + public void SkillsTab_ClickRaiseTen_EmitsSkillRaiseRequest() + { + var list = new UiPanel { Width = 300 }; + var btn10 = MakeButton(); + var layout = Fake( + (CharacterStatController.ListBoxId, list), + (CharacterStatController.RaiseTenId, btn10)); + var requests = new List(); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16), + onRaiseRequest: requests.Add); + + ClickTab(layout, left: 92f); + SkillRows(list)[1].OnClick!(); + btn10.OnClick!(); + + var request = Assert.Single(requests); + Assert.Equal(CharacterStatController.RaiseTargetKind.Skill, request.Kind); + Assert.Equal(34u, request.StatId); + Assert.Equal(111_000_000L, request.Cost); + Assert.Equal(10, request.Amount); + } + [Fact] public void SkillsTab_SelectHealing_ShowsUntrainedSkillFooter() { @@ -800,7 +1040,7 @@ public class CharacterStatControllerTests spriteResolve: id => (id, 16, 16)); ClickTab(layout, left: 92f); - list.Children.OfType().ToList()[5].OnClick!(); + SkillRows(list)[5].OnClick!(); Assert.Equal("Healing", title.LinesProvider()[0].Text); Assert.Equal("Skill Credits To Raise:", l1Label.LinesProvider()[0].Text); @@ -811,6 +1051,133 @@ public class CharacterStatControllerTests Assert.Equal("Normal", btn1.ActiveState); } + [Fact] + public void SkillsTab_ClickUntrainedSkill_EmitsTrainSkillRequest() + { + var list = new UiPanel { Width = 300 }; + var btn1 = MakeButton(); + var layout = Fake( + (CharacterStatController.ListBoxId, list), + (CharacterStatController.RaiseOneId, btn1)); + var requests = new List(); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16), + onRaiseRequest: requests.Add); + + ClickTab(layout, left: 92f); + SkillRows(list)[5].OnClick!(); + btn1.OnClick!(); + + var request = Assert.Single(requests); + Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind); + Assert.Equal(21u, request.StatId); + Assert.Equal(6L, request.Cost); + Assert.Equal(1, request.Amount); + } + + [Fact] + public void SkillsTab_ClickTrain_RebuildsSelectedSkillAsTrained() + { + var list = new UiPanel { Width = 300 }; + var btn1 = MakeButton(); + var btn10 = MakeButton(); + CharacterSheet sheet = new() + { + SkillCredits = 10, + UnassignedXp = 1_000, + Skills = new[] + { + new CharacterSkill(100u, "Train Me", 0x06000001u, + CharacterSkillAdvancementClass.Untrained, + BaseLevel: 5, + CurrentLevel: 5, + UsableUntrained: true, + TrainedCost: 4, + SpecializedCost: 0, + RaiseCost: 0, + Raise10Cost: 0), + }, + }; + var layout = Fake( + (CharacterStatController.ListBoxId, list), + (CharacterStatController.RaiseOneId, btn1), + (CharacterStatController.RaiseTenId, btn10)); + var requests = new List(); + + CharacterStatController.Bind(layout, () => sheet, + spriteResolve: id => (id, 16, 16), + onRaiseRequest: request => + { + requests.Add(request); + sheet = new CharacterSheet + { + SkillCredits = 6, + UnassignedXp = 1_000, + Skills = new[] + { + new CharacterSkill(100u, "Train Me", 0x06000001u, + CharacterSkillAdvancementClass.Trained, + BaseLevel: 5, + CurrentLevel: 5, + UsableUntrained: true, + TrainedCost: 4, + SpecializedCost: 0, + RaiseCost: 10, + Raise10Cost: 100), + }, + }; + }); + + ClickTab(layout, left: 92f); + SkillRows(list).Single().OnClick!(); + Assert.False(btn10.Visible); + + btn1.OnClick!(); + + var request = Assert.Single(requests); + Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind); + Assert.Equal("Train Me", SkillRows(list).Single().Children.OfType().ToList()[1].LinesProvider()[0].Text); + Assert.True(btn1.Visible); + Assert.True(btn10.Visible); + Assert.Equal("Normal", btn1.ActiveState); + Assert.Equal("Normal", btn10.ActiveState); + } + + [Fact] + public void SkillsTab_BindsCharacterScrollbarToScrollableViewport() + { + var root = new UiPanel { Width = 300, Height = 600 }; + var page = new UiPanel { Width = 300, Height = 600 }; + var name = new UiText(); + var list = MakeDatElement(CharacterStatController.ListBoxId, top: 137, width: 300, height: 80); + var scrollbarShell = MakeDatElement(CharacterStatController.ListScrollbarId, top: 137, width: 16, height: 80); + scrollbarShell.Left = 281; + + page.AddChild(name); + page.AddChild(list); + page.AddChild(scrollbarShell); + root.AddChild(page); + + var layout = new ImportedLayout(root, new Dictionary + { + [CharacterStatController.NameId] = name, + [CharacterStatController.ListBoxId] = list, + [CharacterStatController.ListScrollbarId] = scrollbarShell, + }); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + ClickTab(layout, left: 92f); + + var bar = page.Children.OfType().Single(); + Assert.True(bar.Visible); + Assert.NotNull(bar.Model); + Assert.Contains(list.Children, c => c is UiScrollablePanel); + Assert.False(scrollbarShell.Visible); + } + [Fact] public void GetRaiseCost_Index4Focus_Returns110() { @@ -818,6 +1185,13 @@ public class CharacterStatControllerTests Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4)); } + [Fact] + public void GetRaiseCost_Amount10Index4Focus_Returns1100() + { + var sheet = SampleData.SampleCharacter(); + Assert.Equal(1_100L, CharacterStatController.GetRaiseCost(sheet, 4, amount: 10)); + } + [Fact] public void GetRaiseCost_Index0Strength_Returns0() { @@ -868,10 +1242,22 @@ public class CharacterStatControllerTests Assert.Equal(9, costs.Length); } + [Fact] + public void SampleCharacter_AttributeRaise10Costs_HasNineEntries() + { + var costs = SampleData.SampleCharacter().AttributeRaise10Costs; + Assert.NotNull(costs); + Assert.Equal(9, costs.Length); + } + [Fact] public void SampleCharacter_AttributeRaiseCosts_FocusAt110() => Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]); + [Fact] + public void SampleCharacter_AttributeRaise10Costs_FocusAt1100() + => Assert.Equal(1_100L, SampleData.SampleCharacter().AttributeRaise10Costs[4]); + [Fact] public void SampleCharacter_AttributeRaiseCosts_StrengthAt0() => Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]); @@ -1098,12 +1484,32 @@ public class CharacterStatControllerTests private static string FirstRowName(UiElement list) { - var row = list.Children.OfType().FirstOrDefault(); + var row = Descendants(list).OfType().FirstOrDefault(); if (row is null) return ""; var texts = row.Children.OfType().ToList(); return texts.Count > 1 ? texts[1].LinesProvider()[0].Text : ""; } + private static List SkillRows(UiElement list) + => Descendants(list).OfType().ToList(); + + private static List SkillHeaders(UiElement list) + => Descendants(list) + .Where(c => c is UiPanel and not UiClickablePanel + && c.Children.OfType().Any()) + .Cast() + .ToList(); + + private static IEnumerable Descendants(UiElement root) + { + foreach (var child in root.Children) + { + yield return child; + foreach (var nested in Descendants(child)) + yield return nested; + } + } + private static UiDatElement MakeDatElement(uint id, float top, float width, float height) { var info = new ElementInfo @@ -1123,9 +1529,9 @@ public class CharacterStatControllerTests } /// Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites). - private static UiButton MakeButton() + private static UiButton MakeButton(uint id = 0u) { - var info = new ElementInfo { Id = 0, Type = 1 }; + var info = new ElementInfo { Id = id, Type = 1 }; return new UiButton(info, static _ => (0u, 0, 0)); } diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index aab080cd..8cb07115 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -136,6 +136,19 @@ public class ChatWindowControllerTests // ── Test 3: Input is placed as a child of the input bar ───────────────── + [Fact] + public void Bind_Transcript_ForcesScrollableTextMode() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); + + Assert.NotNull(ctrl); + Assert.False(ctrl!.Transcript.Centered); + Assert.False(ctrl.Transcript.RightAligned); + } + [Fact] public void Bind_Input_IsChildOfInputBar() { diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 12871f40..2b1f283d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -63,6 +63,18 @@ public class DatWidgetFactoryTests Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors); } + [Fact] + public void Create_PropagatesStateCursors() + { + var info = new ElementInfo { Type = 3 }; + info.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06008888u, 7, 8); + + var e = DatWidgetFactory.Create(info, NoTex, null)!; + + Assert.Equal(new UiCursorMedia(0x06008888u, 7, 8), + e.CursorForState("Drag_rollover_accept", allowFallback: false)); + } + // ── Test 5: ReadOrder propagated to ZOrder ─────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs index 387767bb..76127445 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs @@ -148,6 +148,22 @@ public class ElementReaderTests Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]); } + [Fact] + public void Merge_DerivedStateCursorOverridesBase() + { + var base_ = new ElementInfo(); + base_.StateCursors[""] = new UiCursorMedia(0x06003000u, 1, 2); + base_.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06003001u, 3, 4); + + var derived = new ElementInfo(); + derived.StateCursors[""] = new UiCursorMedia(0x06004000u, 5, 6); + + var merged = ElementReader.Merge(base_, derived); + + Assert.Equal(new UiCursorMedia(0x06004000u, 5, 6), merged.StateCursors[""]); + Assert.Equal(new UiCursorMedia(0x06003001u, 3, 4), merged.StateCursors["Drag_rollover_accept"]); + } + [Fact] public void Merge_ChildrenComeFromDerived() { diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index efa69730..8357f975 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -22,6 +22,7 @@ public class InventoryControllerTests private const uint BurdenText = 0x100001D8u; private const uint BurdenCaption = 0x100001D7u; private const uint ContentsCaption = 0x100001C5u; + private const uint TitleText = 0x100001D3u; private const uint ContentsScrollbar = 0x100001C7u; private static (ImportedLayout layout, UiItemList grid, UiItemList containers, @@ -35,16 +36,17 @@ public class InventoryControllerTests var burdenText = new TestElement { Width = 36, Height = 15 }; var burdenCap = new TestElement { Width = 36, Height = 15 }; var contentsCap = new TestElement { Width = 192, Height = 15 }; + var titleText = new TestElement { Width = 276, Height = 25 }; var scrollbar = new UiScrollbar { Width = 16, Height = 96 }; var root = new TestElement { Width = 300, Height = 362 }; root.AddChild(grid); root.AddChild(containers); root.AddChild(top); root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap); - root.AddChild(contentsCap); root.AddChild(scrollbar); + root.AddChild(contentsCap); root.AddChild(titleText); root.AddChild(scrollbar); var byId = new Dictionary { [ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top, [BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap, - [ContentsCaption] = contentsCap, [ContentsScrollbar] = scrollbar, + [ContentsCaption] = contentsCap, [TitleText] = titleText, [ContentsScrollbar] = scrollbar, }; return (new ImportedLayout(root, byId), grid, containers, top, meter, burdenText, burdenCap, contentsCap); @@ -52,13 +54,23 @@ public class InventoryControllerTests private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, int? strength = 100, List? uses = null, List? closes = null, - List<(uint item, uint container, int placement)>? puts = null) + List<(uint item, uint container, int placement)>? puts = null, + string? ownerName = null, + Action? onClose = null) => InventoryController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0u, strength: () => strength, datFont: null, + ownerName: ownerName is null ? null : () => ownerName, sendUse: uses is null ? null : g => uses.Add(g), sendNoLongerViewing: closes is null ? null : g => closes.Add(g), - sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p))); + sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)), + onClose: onClose); + + private static UiButton MakeButton(uint id) + { + var info = new ElementInfo { Id = id, Type = 1 }; + return new UiButton(info, static _ => (0u, 0, 0)) { Width = 16, Height = 16 }; + } // Seed a side bag (a container) in the player's pack, plus optionally its own contents. private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24) @@ -100,6 +112,23 @@ public class InventoryControllerTests Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame } + [Fact] + public void Populate_uses_manifest_container_hint_before_create_object_details() + { + var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + objects.ReplaceContents(Player, new[] + { + new ContainerContentEntry(0xC, 1u), + new ContainerContentEntry(0xA, 0u), + }); + + Bind(layout, objects); + + Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); + Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); + } + [Fact] public void Equipped_items_are_excluded_from_the_grid_and_selector() { @@ -125,6 +154,7 @@ public class InventoryControllerTests var (layout, grid, _, _, _, _, _, _) = BuildLayout(); Bind(layout, new ClientObjectTable()); Assert.Equal(6, grid.Columns); + Assert.Equal(UiItemListFlow.RowMajor, grid.Flow); Assert.Equal(32f, grid.CellWidth); Assert.Equal(32f, grid.CellHeight); } @@ -164,11 +194,27 @@ public class InventoryControllerTests public void Captions_render_known_strings() { var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout(); - Bind(layout, new ClientObjectTable()); + var title = layout.FindElement(TitleText)!; + Bind(layout, new ClientObjectTable(), ownerName: "Horan"); + Assert.Contains("Inventory of Horan", CaptionText(title)); Assert.Contains("Burden", CaptionText(burdenCap)); Assert.Contains("Contents of Backpack", CaptionText(contentsCap)); } + [Fact] + public void Window_chrome_button_invokes_close_callback() + { + var (layout, _, _, _, _, _, _, _) = BuildLayout(); + var close = MakeButton(WindowChromeController.InventoryCloseButtonId); + layout.Root.AddChild(close); + int closes = 0; + + Bind(layout, new ClientObjectTable(), onClose: () => closes++); + close.OnEvent(new UiEvent(0u, close, UiEventType.Click)); + + Assert.Equal(1, closes); + } + [Fact] public void Burden_reads_wire_EncumbranceVal_over_carried_sum() { @@ -354,6 +400,42 @@ public class InventoryControllerTests Assert.Empty(closes); } + [Fact] + public void TargetMode_suppressesSelectedSquare_onPendingSource() + { + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Type = ItemType.Creature, + ItemsCapacity = 102, + }); + SeedContained(objects, 0xA, Player, slot: 0); + objects.Get(0xA)!.Useability = 0x000A0008u; + var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + nowMs: () => 1_000); + + InventoryController.Bind(layout, objects, () => Player, + iconIds: (_, _, _, _, _) => 0u, + strength: () => 100, + datFont: null, + itemInteraction: interaction); + grid.GetItem(0)!.Clicked!(); + Assert.True(grid.GetItem(0)!.Selected); + + Assert.True(interaction.ActivateItem(0xAu)); + + Assert.True(interaction.IsTargetModeActive); + Assert.False(grid.GetItem(0)!.Selected); + } + [Fact] public void Default_mainPackCell_isOpenContainer() { diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs index 57269580..71d7c6e9 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs @@ -2,6 +2,7 @@ using System; using System.IO; using AcDream.App.UI; using AcDream.App.UI.Layout; +using AcDream.Core.Items; using DatReaderWriter; using DatReaderWriter.Options; using Xunit; @@ -86,4 +87,34 @@ public class InventoryFrameImportProbe "(else the Alphablend backdrop overpaints/washes out the panel content)"); } } + + [Fact] + public void Close_button_resolves_and_invokes_controller_close_callback() + { + var datDir = DatDir(); + if (datDir is null) return; // CI: no live dat - skip + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null); + Assert.NotNull(layout); + + var close = layout!.FindElement(WindowChromeController.InventoryCloseButtonId); + Assert.NotNull(close); + Assert.True(close is UiButton or UiDatElement, + $"inventory close button resolved to {close?.GetType().Name ?? "null"}"); + + int closes = 0; + InventoryController.Bind( + layout, + new ClientObjectTable(), + playerGuid: static () => 0u, + iconIds: static (_, _, _, _, _) => 0u, + strength: static () => 100, + datFont: null, + onClose: () => closes++); + + close!.OnEvent(new UiEvent(0u, close, UiEventType.Click)); + + Assert.Equal(1, closes); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs index 34657064..a6a14dbc 100644 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs @@ -40,6 +40,22 @@ public class LayoutImporterTests /// draws nothing until a controller binds its LinesProvider); /// the Type-3 must also be present. /// + [Fact] + public void BuildFromInfos_StampsDatElementId_OnRootAndItemList() + { + const uint RootId = 0x100005F9u; + const uint ListId = 0x100001C6u; + + var root = new ElementInfo { Id = RootId, Type = 3, Width = 160, Height = 58 }; + var itemList = new ElementInfo { Id = ListId, Type = 0x10000031u, X = 5, Y = 5, Width = 72, Height = 72 }; + + var tree = LayoutImporter.BuildFromInfos(root, new[] { itemList }, NoTex, null); + + Assert.Equal(RootId, tree.Root.DatElementId); + var found = Assert.IsType(tree.FindElement(ListId)); + Assert.Equal(ListId, found.DatElementId); + } + [Fact] public void BuildFromInfos_Type12Child_IsSkipped_Type3Present() { diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index add55b4f..5e6ee14c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -11,6 +11,8 @@ public class PaperdollControllerTests private const uint Player = 0x50000001u; private const uint Pack = 0x40000005u; private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1 + private const uint ChestSlot = 0x100001E2u; // ChestWear 0x2 + private const uint ChestArmorSlot = 0x100005ACu; // ChestArmor 0x200 private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000 private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000 private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000 @@ -19,7 +21,7 @@ public class PaperdollControllerTests private static (ImportedLayout layout, Dictionary lists) BuildLayout() { - var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot }; + var ids = new[] { HeadSlot, ChestSlot, ChestArmorSlot, ShieldSlot, WeaponSlot, FingerLSlot }; var lists = new Dictionary(); var byId = new Dictionary(); var root = new RootElement { Width = 224, Height = 214 }; @@ -112,6 +114,47 @@ public class PaperdollControllerTests Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only } + [Fact] + public void HandleDropRelease_onAutoWearSlot_sendsFullValidLocations() + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + const EquipMask coatMask = + EquipMask.ChestWear + | EquipMask.UpperArmWear + | EquipMask.LowerArmWear; + SeedPackItem(objects, 0xE02u, coatMask); + var wields = new List<(uint item, uint mask)>(); + var ctrl = Bind(layout, objects, wields); + var payload = new ItemDragPayload(0xE02u, ItemDragSource.Inventory, 0, lists[ChestSlot].Cell); + + ctrl.HandleDropRelease(lists[ChestSlot], lists[ChestSlot].Cell, payload); + + Assert.Equal((uint)coatMask, wields[0].mask); + Assert.Equal(coatMask, objects.Get(0xE02u)!.CurrentlyEquippedLocation); + } + + [Fact] + public void HandleDropRelease_onArmorAutoWearSlot_sendsFullValidLocations() + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + const EquipMask hauberkMask = + EquipMask.ChestArmor + | EquipMask.AbdomenArmor + | EquipMask.UpperArmArmor + | EquipMask.LowerArmArmor; + SeedPackItem(objects, 0xE03u, hauberkMask); + var wields = new List<(uint item, uint mask)>(); + var ctrl = Bind(layout, objects, wields); + var payload = new ItemDragPayload(0xE03u, ItemDragSource.Inventory, 0, lists[ChestArmorSlot].Cell); + + ctrl.HandleDropRelease(lists[ChestArmorSlot], lists[ChestArmorSlot].Cell, payload); + + Assert.Equal((uint)hauberkMask, wields[0].mask); + Assert.Equal(hauberkMask, objects.Get(0xE03u)!.CurrentlyEquippedLocation); + } + [Fact] public void Empty_equip_slot_shows_the_configured_frame() // visible frame so all positions are usable (Slice 1; the doll is Slice 2) { diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 6f590738..53a14ef2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -18,6 +18,8 @@ public class ToolbarControllerTests // The four mutually-exclusive combat-mode indicator element ids (must match ToolbarController's list). private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u }; + private const uint CharacterButtonId = 0x10000199u; + private const uint InventoryButtonId = 0x100001B1u; private static (ImportedLayout layout, Dictionary slots, Dictionary indicators) FakeToolbar() @@ -34,6 +36,8 @@ public class ToolbarControllerTests var e = new UiPanel { Visible = true }; dict[id] = e; indicators[id] = e; root.AddChild(e); } + AddButton(CharacterButtonId); + AddButton(InventoryButtonId); return (new ImportedLayout(root, dict), slots, indicators); void AddSlot(uint id) @@ -41,6 +45,23 @@ public class ToolbarControllerTests var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 }; dict[id] = list; slots[id] = list; root.AddChild(list); } + + void AddButton(uint id) + { + var info = new ElementInfo + { + Id = id, + Type = 1, + Width = 32, + Height = 32, + DefaultStateName = "Normal", + }; + info.StateMedia["Normal"] = (0x1u, 1); + info.StateMedia["Highlight"] = (0x2u, 1); + var button = new UiButton(info, _ => (0u, 0, 0)) { Width = 32, Height = 32 }; + dict[id] = button; + root.AddChild(button); + } } [Fact] @@ -95,6 +116,93 @@ public class ToolbarControllerTests Assert.Equal(0x5001u, used); } + [Fact] + public void WindowToggleButtons_clickCallbacks_fire() + { + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + int inventoryClicks = 0; + int characterClicks = 0; + + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + ctrl.BindWindowToggles( + toggleInventory: () => inventoryClicks++, + toggleCharacter: () => characterClicks++); + + ((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click)); + ((UiButton)layout.FindElement(CharacterButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click)); + + Assert.Equal(1, inventoryClicks); + Assert.Equal(1, characterClicks); + } + + [Fact] + public void InventoryButton_whenTargetModeActive_targetsPlayerInsteadOfTogglingInventory() + { + const uint player = 0x50000001u; + const uint pack = 0x50000010u; + const uint kit = 0x50000A01u; + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature }); + repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container, ItemsCapacity = 24 }); + repo.MoveItem(pack, player, 0); + repo.AddOrUpdate(new ClientObject { ObjectId = kit, Type = ItemType.Misc, Useability = 0x000A0008u }); + repo.MoveItem(kit, pack, 0); + var useWithTarget = new List<(uint Source, uint Target)>(); + var interaction = new ItemInteractionController( + repo, + playerGuid: () => player, + sendUse: null, + sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)), + sendWield: null, + sendDrop: null, + nowMs: () => 1_000); + int inventoryClicks = 0; + + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, + useItem: _ => { }, + itemInteraction: interaction); + ctrl.BindWindowToggles( + toggleInventory: () => inventoryClicks++, + toggleCharacter: () => { }); + interaction.ActivateItem(kit); + + ((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click)); + + Assert.Equal(0, inventoryClicks); + Assert.Equal(new[] { (kit, player) }, useWithTarget); + Assert.False(interaction.IsTargetModeActive); + } + + [Fact] + public void WindowButtonState_highlightsWhenOpen() + { + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + var ctrl = ToolbarController.Bind(layout, repo, + () => Array.Empty(), + iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); + var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!; + var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!; + + ctrl.SetInventoryOpen(true); + ctrl.SetCharacterOpen(true); + + Assert.Equal("Highlight", inventoryButton.ActiveState); + Assert.Equal("Highlight", characterButton.ActiveState); + + ctrl.SetInventoryOpen(false); + ctrl.SetCharacterOpen(false); + + Assert.Equal("Normal", inventoryButton.ActiveState); + Assert.Equal("Normal", characterButton.ActiveState); + } + // ── C1: combat-mode indicator tests ───────────────────────────────────── /// diff --git a/tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs b/tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs new file mode 100644 index 00000000..877b54e3 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs @@ -0,0 +1,61 @@ +using AcDream.App.Rendering; +using AcDream.App.UI; +using DatReaderWriter; +using DatReaderWriter.Options; +using SysEnv = System.Environment; + +namespace AcDream.App.Tests.UI; + +public sealed class RetailCursorCatalogTests +{ + [Theory] + [InlineData(CursorFeedbackKind.TargetPending, 0x27u, 14, 14)] + [InlineData(CursorFeedbackKind.TargetValid, 0x28u, 14, 14)] + [InlineData(CursorFeedbackKind.TargetInvalid, 0x29u, 14, 14)] + public void TargetUseCursorSpecs_matchClientUISystemUpdateCursorState( + CursorFeedbackKind kind, + uint expectedEnum, + int expectedHotspotX, + int expectedHotspotY) + { + Assert.Equal(6u, RetailCursorCatalog.CursorEnumTable); + + Assert.True(RetailCursorCatalog.TryGetGlobalCursor(kind, out var spec)); + + Assert.Equal(expectedEnum, spec.EnumId); + Assert.Equal(expectedHotspotX, spec.HotspotX); + Assert.Equal(expectedHotspotY, spec.HotspotY); + } + + [Fact] + public void TargetUseCursorSpecs_resolveGoldenDatSurfaces() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var resolver = new RetailCursorResolver(dats, new object()); + + Assert.True(resolver.TryResolve(new RetailCursorSpec(0x27u, 14, 14), out var pending)); + Assert.True(resolver.TryResolve(new RetailCursorSpec(0x28u, 14, 14), out var valid)); + Assert.True(resolver.TryResolve(new RetailCursorSpec(0x29u, 14, 14), out var invalid)); + + Assert.Equal(new UiCursorMedia(0x06004D73u, 14, 14), pending); + Assert.Equal(new UiCursorMedia(0x06005E6Bu, 14, 14), valid); + Assert.Equal(new UiCursorMedia(0x06005E6Au, 14, 14), invalid); + } + + private static string? ResolveDatDir() + { + string? fromEnv = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + + string defaultDir = Path.Combine( + SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + return Directory.Exists(defaultDir) ? defaultDir : null; + } +} diff --git a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs new file mode 100644 index 00000000..dc01b9dc --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs @@ -0,0 +1,179 @@ +using System.Collections.Generic; +using System.IO; +using AcDream.App.UI; +using AcDream.App.UI.Testing; +using AcDream.Core.Items; + +namespace AcDream.App.Tests.UI; + +public sealed class RetailUiAutomationProbeTests +{ + private sealed class SpyHandler : IItemListDragHandler + { + public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop; + public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift; + + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) + => LastLift = (sourceList, sourceCell, payload); + + public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + => true; + + public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + => LastDrop = (targetList, targetCell, payload); + } + + private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects) + RootWithTwoItemLists() + { + var root = new UiRoot { Width = 240, Height = 120 }; + var objects = new ClientObjectTable(); + + var source = new UiItemList(_ => (1u, 1, 1)) + { + DatElementId = 0x10000010u, + Left = 10, + Top = 10, + Width = 32, + Height = 32, + }; + source.Cell.SlotIndex = 0; + source.Cell.SourceKind = ItemDragSource.Inventory; + source.Cell.SetItem(0x5001u, 0x99u); + + var target = new UiItemList(_ => (1u, 1, 1)) + { + DatElementId = 0x10000020u, + Left = 70, + Top = 10, + Width = 32, + Height = 32, + }; + target.Cell.SlotIndex = 1; + + var handler = new SpyHandler(); + target.RegisterDragHandler(handler); + + root.AddChild(source); + root.AddChild(target); + return (root, source, target, handler, objects); + } + + [Fact] + public void Snapshot_listsDatElementsAndItemSlots() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var probe = new RetailUiAutomationProbe(root, objects); + + var rows = probe.Snapshot(); + + Assert.Contains(rows, r => r.DatElementId == 0x10000010u && r.TypeName == nameof(UiItemList)); + var itemRow = Assert.Single(rows, r => r.ItemId == 0x5001u); + Assert.Equal(ItemDragSource.Inventory, itemRow.SourceKind); + Assert.Equal(0, itemRow.SlotIndex); + Assert.True(itemRow.Width > 0f); + Assert.True(itemRow.Height > 0f); + } + + [Fact] + public void DoubleClickItem_routesThroughUiRootDoubleClick() + { + var (root, source, _, _, objects) = RootWithTwoItemLists(); + bool doubleClicked = false; + source.Cell.DoubleClicked = () => doubleClicked = true; + var probe = new RetailUiAutomationProbe(root, objects); + + Assert.True(probe.DoubleClickItem(0x5001u, ItemDragSource.Inventory)); + + Assert.True(doubleClicked); + } + + [Fact] + public void DragItemToElement_deliversDropReleaseToTargetCell() + { + var (root, _, target, handler, objects) = RootWithTwoItemLists(); + var probe = new RetailUiAutomationProbe(root, objects); + + Assert.True(probe.DragItemToElement(0x5001u, 0x10000020u, ItemDragSource.Inventory)); + + Assert.NotNull(handler.LastDrop); + Assert.Same(target, handler.LastDrop!.Value.list); + Assert.Same(target.Cell, handler.LastDrop.Value.cell); + Assert.Equal(0x5001u, handler.LastDrop.Value.payload.ObjId); + } + + [Fact] + public void DragItemOutside_raisesRootOutsideUiEvent() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + object? payload = null; + (int x, int y) release = default; + root.DragReleasedOutsideUi += (p, x, y) => + { + payload = p; + release = (x, y); + }; + var probe = new RetailUiAutomationProbe(root, objects); + + Assert.True(probe.DragItemOutside(0x5001u, 220, 100, ItemDragSource.Inventory)); + + var itemPayload = Assert.IsType(payload); + Assert.Equal(0x5001u, itemPayload.ObjId); + Assert.Equal((220, 100), release); + } + + [Fact] + public void AssertItem_checksObjectTableState() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = 0x5001u, + ContainerId = 0x7001u, + ContainerSlot = 3, + CurrentlyEquippedLocation = EquipMask.ChestArmor | EquipMask.UpperArmArmor, + }); + var probe = new RetailUiAutomationProbe(new UiRoot(), objects); + + var ok = probe.AssertItem( + 0x5001u, + equippedLocation: EquipMask.ChestArmor | EquipMask.UpperArmArmor, + containerId: 0x7001u, + slot: 3); + var bad = probe.AssertItem(0x5001u, slot: 4); + + Assert.True(ok.Success); + Assert.False(bad.Success); + } + + [Fact] + public void ScriptRunner_waitItemThenDoubleClick_executesThroughProbe() + { + var (root, source, _, _, objects) = RootWithTwoItemLists(); + bool doubleClicked = false; + source.Cell.DoubleClicked = () => doubleClicked = true; + var probe = new RetailUiAutomationProbe(root, objects); + var logs = new List(); + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllLines(path, new[] + { + "wait item 0x5001 inventory 100", + "doubleclick item 0x5001 inventory", + }); + + try + { + var runner = new RetailUiAutomationScriptRunner(probe, path, dumpOnStart: false, logs.Add); + + runner.Tick(0.016); + + Assert.True(doubleClicked); + Assert.True(runner.Completed); + Assert.Contains(logs, line => line.Contains("UI probe script complete")); + } + finally + { + File.Delete(path); + } + } +} diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs new file mode 100644 index 00000000..6e27b3a9 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs @@ -0,0 +1,291 @@ +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.App.UI.Testing; +using AcDream.Core.Items; + +namespace AcDream.App.Tests.UI; + +public sealed class RetailUiInteractionFlowTests +{ + private const uint Player = 0x50000001u; + private const uint Hauberk = 0x50001001u; + private const uint HealthKit = 0x50001002u; + private const uint SlotsButtonId = 0x100005BEu; + private const uint ChestArmorSlotId = 0x100005ACu; + private const uint HealthKitUseability = 0x000A0008u; + + private const EquipMask HauberkMask = + EquipMask.ChestArmor + | EquipMask.AbdomenArmor + | EquipMask.UpperArmArmor + | EquipMask.LowerArmArmor; + + private sealed class TestElement : UiElement { } + + private sealed class Harness + { + public readonly UiRoot Root = new() { Width = 800, Height = 600 }; + public readonly ClientObjectTable Objects = new(); + public readonly ImportedLayout Layout; + public readonly List Uses = new(); + public readonly List<(uint Source, uint Target)> UseWithTarget = new(); + public readonly List<(uint Item, uint Mask)> Wields = new(); + public readonly List Drops = new(); + public long Now = 10_000; + + public Harness() + { + var layoutRoot = new TestElement { Width = 360, Height = 180 }; + var grid = ItemList( + InventoryController.ContentsGridId, + left: 10, + top: 10, + width: 192, + height: 96); + var sideBags = ItemList( + InventoryController.ContainerListId, + left: 210, + top: 10, + width: 36, + height: 96); + var mainPack = ItemList( + InventoryController.TopContainerId, + left: 210, + top: 112, + width: 36, + height: 36); + var chestArmor = ItemList( + ChestArmorSlotId, + left: 280, + top: 10, + width: 32, + height: 32); + var slotsButton = new UiButton( + new ElementInfo { Id = SlotsButtonId, Type = 1 }, + static _ => (0u, 0, 0)) + { + DatElementId = SlotsButtonId, + Left = 270, + Top = 56, + Width = 56, + Height = 20, + }; + var meter = new UiMeter + { + DatElementId = InventoryController.BurdenMeterId, + Left = 248, + Top = 10, + Width = 11, + Height = 58, + }; + var titleText = TextHost(InventoryController.TitleTextId, 10, 116, 192, 14); + var burdenText = TextHost(InventoryController.BurdenTextId, 248, 72, 36, 14); + var burdenCaption = TextHost(InventoryController.BurdenCaptionId, 248, 88, 36, 14); + var contentsCaption = TextHost(InventoryController.ContentsCaptionId, 10, 132, 192, 14); + var scrollbar = new UiScrollbar + { + DatElementId = InventoryController.ContentsScrollbarId, + Left = 192, + Top = 10, + Width = 16, + Height = 96, + }; + + var byId = new Dictionary + { + [InventoryController.ContentsGridId] = grid, + [InventoryController.ContainerListId] = sideBags, + [InventoryController.TopContainerId] = mainPack, + [InventoryController.BurdenMeterId] = meter, + [InventoryController.TitleTextId] = titleText, + [InventoryController.BurdenTextId] = burdenText, + [InventoryController.BurdenCaptionId] = burdenCaption, + [InventoryController.ContentsCaptionId] = contentsCaption, + [InventoryController.ContentsScrollbarId] = scrollbar, + [ChestArmorSlotId] = chestArmor, + [SlotsButtonId] = slotsButton, + }; + + layoutRoot.AddChild(grid); + layoutRoot.AddChild(sideBags); + layoutRoot.AddChild(mainPack); + layoutRoot.AddChild(chestArmor); + layoutRoot.AddChild(slotsButton); + layoutRoot.AddChild(meter); + layoutRoot.AddChild(titleText); + layoutRoot.AddChild(burdenText); + layoutRoot.AddChild(burdenCaption); + layoutRoot.AddChild(contentsCaption); + layoutRoot.AddChild(scrollbar); + Layout = new ImportedLayout(layoutRoot, byId); + Root.AddChild(layoutRoot); + + Objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Name = "Player", + Type = ItemType.Creature, + ItemsCapacity = 102, + ContainersCapacity = 7, + }); + } + + public ItemInteractionController BindInventoryInteraction() + { + var interaction = new ItemInteractionController( + Objects, + playerGuid: () => Player, + sendUse: Uses.Add, + sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)), + sendWield: (item, mask) => Wields.Add((item, mask)), + sendDrop: Drops.Add, + nowMs: () => Now); + + InventoryController.Bind( + Layout, + Objects, + playerGuid: () => Player, + iconIds: static (_, _, _, _, _) => 0x1234u, + strength: () => 100, + datFont: null, + itemInteraction: interaction); + + Root.DragReleasedOutsideUi += (payload, _, _) => + { + if (payload is ItemDragPayload itemPayload) + interaction.DropToWorld(itemPayload); + }; + + return interaction; + } + + public void BindPaperdoll() + => PaperdollController.Bind( + Layout, + Objects, + playerGuid: () => Player, + iconIds: static (_, _, _, _, _) => 0x1234u, + sendWield: (item, mask) => Wields.Add((item, mask)), + emptySlotSprite: 0x06004D20u); + + public RetailUiAutomationProbe Probe() + => new(Root, Objects); + + public void SeedHauberk(int slot = 0) + { + Objects.AddOrUpdate(new ClientObject + { + ObjectId = Hauberk, + Name = "Hauberk", + Type = ItemType.Armor, + ValidLocations = HauberkMask, + IconId = 0x06001234u, + }); + Objects.MoveItem(Hauberk, Player, slot); + } + + public void SeedHealthKit(int slot = 0) + { + Objects.AddOrUpdate(new ClientObject + { + ObjectId = HealthKit, + Name = "Health Kit", + Type = ItemType.Misc, + IconId = 0x06001235u, + Useability = HealthKitUseability, + }); + Objects.MoveItem(HealthKit, Player, slot); + } + + private static UiItemList ItemList(uint id, float left, float top, float width, float height) + => new(static _ => (1u, 32, 32)) + { + DatElementId = id, + Left = left, + Top = top, + Width = width, + Height = height, + }; + + private static UiElement TextHost(uint id, float left, float top, float width, float height) + => new TestElement + { + DatElementId = id, + Left = left, + Top = top, + Width = width, + Height = height, + ClickThrough = true, + }; + } + + [Fact] + public void InventoryDoubleClick_hauberk_routesThroughUiRootAndEquipsFullMask() + { + var h = new Harness(); + h.SeedHauberk(); + h.BindInventoryInteraction(); + var probe = h.Probe(); + + Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory)); + + Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields); + var state = probe.AssertItem( + Hauberk, + equippedLocation: HauberkMask, + containerId: Player); + Assert.True(state.Success, state.Message); + } + + [Fact] + public void InventoryTargetUse_thenMainPackClick_selfTargetsThroughUiRoot() + { + var h = new Harness(); + h.SeedHealthKit(); + var interaction = h.BindInventoryInteraction(); + var probe = h.Probe(); + + Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory)); + Assert.True(interaction.IsTargetModeActive); + Assert.True(probe.ClickElement(InventoryController.TopContainerId)); + + Assert.Equal(new[] { (HealthKit, Player) }, h.UseWithTarget); + Assert.False(interaction.IsTargetModeActive); + } + + [Fact] + public void InventoryDragOutsideUi_routesThroughRootOutsideHookAndDropsToWorld() + { + var h = new Harness(); + h.SeedHauberk(); + h.BindInventoryInteraction(); + var probe = h.Probe(); + + Assert.True(probe.DragItemOutside(Hauberk, 700, 500, ItemDragSource.Inventory)); + + Assert.Equal(new[] { Hauberk }, h.Drops); + var state = probe.AssertItem(Hauberk, containerId: 0u, slot: -1); + Assert.True(state.Success, state.Message); + } + + [Fact] + public void InventoryDragToPaperdollChestArmorSlot_usesFullHauberkCoverageMask() + { + var h = new Harness(); + h.SeedHauberk(); + h.BindInventoryInteraction(); + h.BindPaperdoll(); + var probe = h.Probe(); + + Assert.True(probe.ClickElement(SlotsButtonId)); // show armor slots; retail defaults to doll-view. + Assert.True(probe.DragItemToElement(Hauberk, ChestArmorSlotId, ItemDragSource.Inventory)); + + Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields); + var state = probe.AssertItem( + Hauberk, + equippedLocation: HauberkMask, + containerId: Player); + Assert.True(state.Success, state.Message); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiItemListGridTests.cs b/tests/AcDream.App.Tests/UI/UiItemListGridTests.cs index 4053eac1..a283aad9 100644 --- a/tests/AcDream.App.Tests/UI/UiItemListGridTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemListGridTests.cs @@ -12,6 +12,14 @@ public class UiItemListGridTests Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1 } + [Fact] + public void CellOffset_ColumnMajor_UsesLogicalRows() + { + Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 7, UiItemListFlow.ColumnMajor, 36, 36)); + Assert.Equal((0f, 36f), UiItemList.CellOffset(1, 3, 7, UiItemListFlow.ColumnMajor, 36, 36)); + Assert.Equal((36f, 0f), UiItemList.CellOffset(3, 3, 7, UiItemListFlow.ColumnMajor, 36, 36)); + } + [Fact] public void GridMode_PositionsCellsInColumns() { @@ -26,6 +34,25 @@ public class UiItemListGridTests Assert.Equal(36f, c4.Height); } + [Fact] + public void GridMode_ColumnMajor_PositionsCellsDownRowsFirst() + { + var list = new UiItemList + { + Columns = 3, + Flow = UiItemListFlow.ColumnMajor, + CellWidth = 36, + CellHeight = 36, + }; + list.Flush(); + for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot()); + + var c3 = list.GetItem(3)!; + Assert.Equal(36f, c3.Left); + Assert.Equal(0f, c3.Top); + } + + [Fact] public void FillMode_SizesSingleCellToList() { diff --git a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs index 773f3039..ad2d5e8a 100644 --- a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs @@ -39,6 +39,18 @@ public class UiItemSlotTests Assert.Equal(0u, s.IconTexture); } + [Fact] + public void DoubleClick_invokesDoubleClicked() + { + var s = new UiItemSlot(); + bool fired = false; + s.DoubleClicked = () => fired = true; + + s.OnEvent(new UiEvent(0u, s, UiEventType.DoubleClick)); + + Assert.True(fired); + } + // ── Shortcut number tests ──────────────────────────────────────────────── // Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index 49799ae1..46807955 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -18,11 +18,14 @@ public class UiRootInputTests { private readonly bool _handlesClick; public bool Clicked; + public int Clicks; + public int DoubleClicks; public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick; public override bool HandlesClick => _handlesClick; public override bool OnEvent(in UiEvent e) { - if (e.Type == UiEventType.Click) { Clicked = true; return true; } + if (e.Type == UiEventType.Click) { Clicked = true; Clicks++; return true; } + if (e.Type == UiEventType.DoubleClick) { DoubleClicks++; return true; } return false; } } @@ -45,6 +48,51 @@ public class UiRootInputTests Assert.True(btn.Clicked); } + [Fact] + public void DoubleClick_EmitsRealDoubleClickEvent() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 }; + root.AddChild(btn); + + root.Tick(0, nowMs: 1_000); + root.OnMouseDown(UiMouseButton.Left, 20, 10); + root.OnMouseUp(UiMouseButton.Left, 20, 10); + root.Tick(0, nowMs: 1_300); + root.OnMouseDown(UiMouseButton.Left, 20, 10); + root.OnMouseUp(UiMouseButton.Left, 20, 10); + + Assert.Equal(2, btn.Clicks); + Assert.Equal(1, btn.DoubleClicks); + } + + [Fact] + public void ItemDragReleasedOutsideUi_raisesOutsideReleaseEvent() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var cell = new UiItemSlot { Left = 0, Top = 0, Width = 32, Height = 32 }; + cell.SetItem(0x50000A01u, 0x99u); + root.AddChild(cell); + object? payload = null; + int releaseX = 0; + int releaseY = 0; + root.DragReleasedOutsideUi += (p, x, y) => + { + payload = p; + releaseX = x; + releaseY = y; + }; + + root.OnMouseDown(UiMouseButton.Left, 5, 5); + root.OnMouseMove(20, 20); + root.OnMouseUp(UiMouseButton.Left, 200, 200); + + var drag = Assert.IsType(payload); + Assert.Equal(0x50000A01u, drag.ObjId); + Assert.Equal(200, releaseX); + Assert.Equal(200, releaseY); + } + [Fact] public void PlainWidget_insideDraggableWindow_doesNotEmitClick() { @@ -327,14 +375,18 @@ public class UiRootInputTests root.AddChild(win); root.RegisterWindow("inventory", win); + Assert.False(root.IsWindowVisible("inventory")); Assert.True(root.ShowWindow("inventory")); Assert.True(win.Visible); + Assert.True(root.IsWindowVisible("inventory")); Assert.True(root.HideWindow("inventory")); Assert.False(win.Visible); + Assert.False(root.IsWindowVisible("inventory")); Assert.False(root.ShowWindow("nope")); Assert.False(root.HideWindow("nope")); Assert.False(root.ToggleWindow("nope")); + Assert.False(root.IsWindowVisible("nope")); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/UiScrollablePanelTests.cs b/tests/AcDream.App.Tests/UI/UiScrollablePanelTests.cs new file mode 100644 index 00000000..cd137317 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiScrollablePanelTests.cs @@ -0,0 +1,39 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public sealed class UiScrollablePanelTests +{ + [Fact] + public void LayoutScrollableChildren_ClipsRowsOutsideViewport() + { + var panel = new UiScrollablePanel { Width = 100, Height = 40, LineHeight = 20 }; + for (int i = 0; i < 4; i++) + panel.AddChild(new UiPanel { Top = i * 20, Width = 100, Height = 20 }); + + panel.LayoutScrollableChildren(); + Assert.True(panel.Children[0].Visible); + Assert.True(panel.Children[1].Visible); + Assert.False(panel.Children[2].Visible); + + panel.Scroll.SetScrollY(20); + panel.LayoutScrollableChildren(); + Assert.False(panel.Children[0].Visible); + Assert.True(panel.Children[1].Visible); + Assert.True(panel.Children[2].Visible); + } + + [Fact] + public void ScrollEvent_MovesByLineHeight() + { + var panel = new UiScrollablePanel { Width = 100, Height = 40, LineHeight = 20 }; + for (int i = 0; i < 4; i++) + panel.AddChild(new UiPanel { Top = i * 20, Width = 100, Height = 20 }); + panel.LayoutScrollableChildren(); + + var e = new UiEvent(0u, panel, UiEventType.Scroll, Data0: -1); + Assert.True(panel.OnEvent(in e)); + + Assert.Equal(20, panel.Scroll.ScrollY); + } +} diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 0bafb2f2..f1c4d52e 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -37,14 +37,14 @@ public sealed class GameEventWiringTests return body; } - private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll() + private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll(Func? playerGuid = null) { var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); - GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat); + GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, playerGuid: playerGuid); return (dispatcher, items, combat, spellbook, chat); } @@ -123,20 +123,20 @@ public sealed class GameEventWiringTests [Fact] public void WireAll_WieldObject_ConfirmsOptimisticWield() { - var (d, items, _, _, _) = MakeAll(); const uint player = 0x2000u; + var (d, items, _, _, _) = MakeAll(() => player); items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 }); items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending - byte[] payload = new byte[12]; + byte[] payload = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), player); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload)); d.Dispatch(env!.Value); // server confirms the wield Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove + Assert.Equal(player, items.Get(0x1500)!.ContainerId); } [Fact] @@ -215,6 +215,59 @@ public sealed class GameEventWiringTests Assert.Equal(0.5f, local.ManaPercent!.Value, precision: 3); } + [Fact] + public void WireAll_PlayerDescription_PopulatesLocalPlayerStateSkills() + { + var dispatcher = new GameEventDispatcher(); + var items = new ClientObjectTable(); + var combat = new CombatState(); + var spellbook = new Spellbook(); + var chat = new ChatLog(); + var local = new LocalPlayerState(); + int callbackRun = -1; + + GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local, + onSkillsUpdated: (run, _) => callbackRun = run, + resolveSkillFormulaBonus: (skillId, attrs) => + { + Assert.Equal(24u, skillId); + Assert.Equal(50u, attrs[1u]); + return 80u; + }); + + var sb = new MemoryStream(); + using var w = new BinaryWriter(sb); + w.Write(0u); // propertyFlags + w.Write(0x52u); // weenieType + w.Write(0x03u); // vectorFlags = Attribute | Skill + w.Write(0u); // has_health = false + w.Write(0x01u); // attribute_flags = Strength only + w.Write(0u); // Strength ranks + w.Write(50u); // Strength start + w.Write(0u); // Strength xp + + w.Write((ushort)1); // skill count + w.Write((ushort)8); // buckets + w.Write(24u); // Run + w.Write((ushort)12); + w.Write((ushort)1); // const_one + w.Write(2u); // trained + w.Write(3456u); // xp + w.Write(30u); // init + w.Write(0u); // resistance + w.Write(1.5); // last used + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); + dispatcher.Dispatch(env!.Value); + + var run = local.GetSkill(24u); + Assert.NotNull(run); + Assert.Equal(122u, run!.Value.CurrentLevel); + Assert.Equal(2u, run.Value.Status); + Assert.Equal(3456u, run.Value.Xp); + Assert.Equal(122, callbackRun); + } + [Fact] public void WireAll_PlayerDescription_FeedsSpellbook() { @@ -392,6 +445,48 @@ public sealed class GameEventWiringTests Assert.Equal(2, items.ObjectCount); Assert.NotNull(items.Get(0x50000A01u)); Assert.NotNull(items.Get(0x50000A02u)); + Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint); + Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); + } + + [Fact] + public void PlayerDescription_ReplacesPlayerPackContents_InRetailManifestOrder() + { + const uint playerGuid = 0x50000001u; + var dispatcher = new GameEventDispatcher(); + var items = new ClientObjectTable(); + var combat = new CombatState(); + var spellbook = new Spellbook(); + var chat = new ChatLog(); + GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, + playerGuid: () => playerGuid); + + var sb = new MemoryStream(); + using var w = new BinaryWriter(sb); + w.Write(0u); // propertyFlags = 0 + w.Write(0x52u); // weenieType + w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT + w.Write(1u); // has_health + w.Write(0u); // attribute_flags = 0 (no attrs) + w.Write(0u); // enchantment_mask = 0 + + w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS -> strict inv path) + w.Write(0u); // options1 + w.Write(0u); // legacy hotbar list count = 0 + w.Write(0u); // spellbook_filters + + w.Write(2u); + w.Write(0x50000A02u); w.Write(1u); + w.Write(0x50000A01u); w.Write(0u); + w.Write(0u); // equipped count + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); + dispatcher.Dispatch(env!.Value); + + Assert.Equal(new[] { 0x50000A02u, 0x50000A01u }, items.GetContents(playerGuid)); + Assert.Equal(playerGuid, items.Get(0x50000A02u)!.ContainerId); + Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); + Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint); } [Fact] @@ -441,6 +536,7 @@ public sealed class GameEventWiringTests Assert.NotNull(items.Get(0x700u)); // (b) WeenieClassId must be 0, NOT the ContainerType discriminator (1) — misuse gone Assert.Equal(0u, items.Get(0x700u)!.WeenieClassId); + Assert.Equal(1u, items.Get(0x700u)!.ContainerTypeHint); // (c) equipped guid has its equip slot set Assert.NotNull(items.Get(0x701u)); Assert.Equal(EquipMask.MeleeWeapon, items.Get(0x701u)!.CurrentlyEquippedLocation); @@ -573,6 +669,9 @@ public sealed class GameEventWiringTests Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId); Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId); + Assert.Equal(new[] { 0x50000A01u, 0x50000A02u }, items.GetContents(0x500000C9u)); + Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint); + Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); } [Fact] @@ -638,13 +737,14 @@ public sealed class GameEventWiringTests BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B02u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x500000C1u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); // placement - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); // containerType + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); // containerType var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjInContainer, payload)); d.Dispatch(env!.Value); // ConfirmMove cleared the pending snapshot → a later rollback is a no-op (the move stuck). Assert.False(items.RollbackMove(0x50000B02u)); Assert.Equal(0x500000C1u, items.Get(0x50000B02u)!.ContainerId); + Assert.Equal(1u, items.Get(0x50000B02u)!.ContainerTypeHint); } [Fact] @@ -661,4 +761,21 @@ public sealed class GameEventWiringTests Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); } + [Fact] + public void WireAll_InventoryPutObjectIn3D_ConfirmsOptimisticDrop() + { + var (d, items, _, _, _) = MakeAll(); + items.RecordMembership(0x50000A03u, containerId: 0x500000C9u); + items.MoveItem(0x50000A03u, 0x500000C9u, 4); + items.MoveItemOptimistic(0x50000A03u, newContainerId: 0u, newSlot: -1); + + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A03u); + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload)); + d.Dispatch(env!.Value); + + Assert.Equal(0u, items.Get(0x50000A03u)!.ContainerId); + Assert.False(items.RollbackMove(0x50000A03u)); + } + } diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs index 58a5a017..5c5f2cec 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -156,6 +156,22 @@ public sealed class CreateObjectTests Assert.Equal(2.5f, parsed.Value.UseRadius!.Value, precision: 3); } + [Fact] + public void TryParse_WeenieFlagsTargetType_ReadsTargetType() + { + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x50000009u, + name: "Healing Kit", + itemType: (uint)ItemType.Misc, + weenieFlags: 0x00080000u, + targetType: (uint)ItemType.Creature); + + var parsed = CreateObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType); + } + // ----------------------------------------------------------------------- // D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the // action bar / equipment UI can read icon dat ids from spawn messages. @@ -451,6 +467,7 @@ public sealed class CreateObjectTests uint? value = null, uint? useability = null, float? useRadius = null, + uint? targetType = null, uint iconOverlayId = 0, uint iconUnderlayId = 0, // intermediate fields for cursor-arithmetic test @@ -516,7 +533,7 @@ public sealed class CreateObjectTests BinaryPrimitives.WriteSingleLittleEndian(tmp, useRadius ?? 0f); bytes.AddRange(tmp.ToArray()); } - if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, 0); // TargetType u32 + if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, targetType ?? 0u); // TargetType u32 if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32 if ((weenieFlags & 0x00000200u) != 0) bytes.Add(0); // CombatUse sbyte/1 byte if ((weenieFlags & 0x00000400u) != 0) WriteU16(bytes, structure ?? 0); // Structure u16 diff --git a/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs index 89e050c9..0f041065 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs @@ -1,6 +1,7 @@ using System; using System.Buffers.Binary; using System.Text; +using AcDream.Core.Items; using AcDream.Core.Net.Messages; using Xunit; @@ -176,6 +177,21 @@ public sealed class GameEventDispatcherTests Assert.Equal(0.42f, parsed.Value.HealthPercent, 4); } + [Fact] + public void ParseWieldObject_acceptsAceEightBytePayload() + { + byte[] payload = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.ChestArmor); + + var parsed = GameEvents.ParseWieldObject(payload); + + Assert.NotNull(parsed); + Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid); + Assert.Equal((uint)EquipMask.ChestArmor, parsed.Value.EquipLoc); + Assert.Equal(0u, parsed.Value.WielderGuid); + } + [Fact] public void ParseWeenieError_RoundTrip() { diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs index 855f77c9..4bf705c5 100644 --- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs @@ -57,6 +57,8 @@ public sealed class ObjectTableWiringTests Structure = 80, MaxStructure = 100, Workmanship = 4.5f, + Useability = 0x000A0008u, + TargetType = (uint)ItemType.Creature, }; var d = ObjectTableWiring.ToWeenieData(spawn); @@ -96,6 +98,8 @@ public sealed class ObjectTableWiringTests Assert.Equal(80, d.Structure); Assert.Equal(100, d.MaxStructure); Assert.Equal(4.5f, d.Workmanship); + Assert.Equal(0x000A0008u, d.Useability); + Assert.Equal((uint)ItemType.Creature, d.TargetType); } // ------------------------------------------------------------------------- diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs index 8ebf8ddb..b22f2732 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs @@ -28,6 +28,58 @@ public sealed class WorldSessionCombatTests CharacterActions.CombatMode.Magic), captured); } + [Fact] + public void SendRaiseAttribute_UsesRetailBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendRaiseAttribute(5u, 110u); + + Assert.NotNull(captured); + Assert.Equal(CharacterActions.BuildRaiseAttribute(1, 5u, 110u), captured); + } + + [Fact] + public void SendRaiseVital_UsesRetailBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendRaiseVital(1u, 90u); + + Assert.NotNull(captured); + Assert.Equal(CharacterActions.BuildRaiseVital(1, 1u, 90u), captured); + } + + [Fact] + public void SendRaiseSkill_UsesRetailBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendRaiseSkill(34u, 111_000_000u); + + Assert.NotNull(captured); + Assert.Equal(CharacterActions.BuildRaiseSkill(1, 34u, 111_000_000u), captured); + } + + [Fact] + public void SendTrainSkill_UsesRetailBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendTrainSkill(21u, 6u); + + Assert.NotNull(captured); + Assert.Equal(CharacterActions.BuildTrainSkill(1, 21u, 6u), captured); + } + [Fact] public void SendMeleeAttack_UsesRetailMeleeBuilder() { @@ -88,4 +140,19 @@ public sealed class WorldSessionCombatTests Assert.NotNull(captured); Assert.Equal(SocialActions.BuildQueryHealth(1, 0x50000007u), captured); } + + [Fact] + public void SendUseWithTarget_UsesRetailBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendUseWithTarget(0x50000A01u, 0x50000001u); + + Assert.NotNull(captured); + Assert.Equal( + InteractRequests.BuildUseWithTarget(1, 0x50000A01u, 0x50000001u), + captured); + } } diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 4c8f524e..509e3763 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -152,18 +152,38 @@ public sealed class ClientObjectTableTests Assert.Equal(0u, repo.Get(0x500000ACu)!.Effects); } + [Fact] + public void UpdateIntProperty_currentWieldedLocation_updatesTypedEquipLocation() + { + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject + { + ObjectId = 0x500000ADu, + CurrentlyEquippedLocation = EquipMask.ChestArmor, + }); + + Assert.True(repo.UpdateIntProperty( + 0x500000ADu, + ClientObjectTable.CurrentWieldedLocationPropertyId, + value: 0)); + + Assert.Equal(EquipMask.None, repo.Get(0x500000ADu)!.CurrentlyEquippedLocation); + Assert.Equal(0, repo.Get(0x500000ADu)!.Properties.Ints[ClientObjectTable.CurrentWieldedLocationPropertyId]); + } + [Fact] public void ClientObject_NewFields_DefaultAndSettable() { var o = new ClientObject { ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7, - Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f, + ContainerTypeHint = 1u, Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f, }; o.WeenieClassId = 0xABCDu; // now settable Assert.Equal(0x42u, o.WielderId); Assert.Equal(24, o.ItemsCapacity); Assert.Equal(7, o.ContainersCapacity); + Assert.Equal(1u, o.ContainerTypeHint); Assert.Equal(8u, o.Priority); Assert.Equal(5, o.Structure); Assert.Equal(10, o.MaxStructure); @@ -239,6 +259,24 @@ public sealed class ClientObjectTableTests Assert.Equal(0u, table.Get(0x500000B3u)!.Effects); } + [Fact] + public void Ingest_UseabilityAndTargetType_AssignsTypedFields() + { + var table = new ClientObjectTable(); + var data = FullWeenie(0x500000B7u) with + { + Useability = 0x000A0008u, + TargetType = (uint)ItemType.Creature, + }; + + table.Ingest(data); + + var item = table.Get(0x500000B7u); + Assert.NotNull(item); + Assert.Equal(0x000A0008u, item!.Useability); + Assert.Equal((uint)ItemType.Creature, item.TargetType); + } + [Fact] public void RecordMembership_CreatesEntry_AndSetsEquip() { @@ -271,6 +309,21 @@ public sealed class ClientObjectTableTests Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x500000B6u)!.CurrentlyEquippedLocation); } + [Fact] + public void RecordMembership_inventoryEntryClearsStaleEquipLocation() + { + var table = new ClientObjectTable(); + table.AddOrUpdate(new ClientObject + { + ObjectId = 0x500000B8u, + CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear, + }); + + table.RecordMembership(0x500000B8u); + + Assert.Equal(EquipMask.None, table.Get(0x500000B8u)!.CurrentlyEquippedLocation); + } + [Fact] public void ContainerIndex_IngestThenContents_OrderedBySlot() { @@ -340,6 +393,40 @@ public sealed class ClientObjectTableTests Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId); } + [Fact] + public void ReplaceContents_listedMemberClearsStaleEquipLocation() + { + var table = new ClientObjectTable(); + table.AddOrUpdate(new ClientObject + { + ObjectId = 0xA10u, + ContainerId = 0x50000001u, + CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear, + }); + + table.ReplaceContents(0x50000001u, new uint[] { 0xA10u }); + + var item = table.Get(0xA10u)!; + Assert.Equal(0x50000001u, item.ContainerId); + Assert.Equal(0, item.ContainerSlot); + Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation); + } + + [Fact] + public void ReplaceContents_detachedMemberClearsStaleEquipLocation() + { + var table = new ClientObjectTable(); + table.AddOrUpdate(new ClientObject { ObjectId = 0xA11u }); + table.MoveItem(0xA11u, 0x50000001u, newSlot: 0, + newEquipLocation: EquipMask.ChestWear | EquipMask.UpperArmWear); + + table.ReplaceContents(0x50000001u, Array.Empty()); + + var item = table.Get(0xA11u)!; + Assert.Equal(0u, item.ContainerId); + Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation); + } + [Fact] public void ReplaceContents_ReordersToNewListOrder() { @@ -349,6 +436,54 @@ public sealed class ClientObjectTableTests Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u)); } + [Fact] + public void ReplaceContents_RecordsContainerTypeHintAndOrder() + { + var table = new ClientObjectTable(); + table.ReplaceContents(0xC9u, new[] + { + new ContainerContentEntry(0xA02u, 1u), + new ContainerContentEntry(0xA01u, 0u), + }); + + Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u)); + Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint); + Assert.Equal(0u, table.Get(0xA01u)!.ContainerTypeHint); + } + + [Fact] + public void ReplaceContents_ManifestOrderSurvivesLaterCreateObjects() + { + var table = new ClientObjectTable(); + const uint player = 0x50000001u; + + table.ReplaceContents(player, new[] + { + new ContainerContentEntry(0xA01u, 0u), + new ContainerContentEntry(0xA02u, 1u), + }); + table.Ingest(FullWeenie(0xA02u, container: player, type: ItemType.Container)); + table.Ingest(FullWeenie(0xA01u, container: player)); + + Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player)); + Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot); + Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot); + Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint); + } + + [Fact] + public void Reindex_UnknownSlotAppendsAfterKnownSnapshotSlots() + { + var table = new ClientObjectTable(); + const uint pack = 0x500000C9u; + + table.ReplaceContents(pack, new uint[] { 0xA01u, 0xA02u }); + table.Ingest(FullWeenie(0xA03u, container: pack)); + + Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(pack)); + Assert.Equal(-1, table.Get(0xA03u)!.ContainerSlot); + } + [Fact] public void ReplaceContents_ZeroContainer_NoOp() { @@ -516,4 +651,22 @@ public sealed class ClientObjectTableTests Assert.Equal(2, o.ContainerSlot); // and slot Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // pre-wield was un-equipped } + + [Fact] + public void UpdateInt64Property_setsValueAndFiresUpdated() + { + var table = new ClientObjectTable(); + table.AddOrUpdate(new ClientObject { ObjectId = 0x960u }); + int updates = 0; + table.ObjectUpdated += _ => updates++; + + Assert.True(table.UpdateInt64Property(0x960u, 2u, 12_345L)); + + Assert.Equal(12_345L, table.Get(0x960u)!.Properties.Int64s[2u]); + Assert.Equal(1, updates); + } + + [Fact] + public void UpdateInt64Property_unknownObject_false() + => Assert.False(new ClientObjectTable().UpdateInt64Property(0xDEADu, 2u, 1L)); } diff --git a/tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs b/tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs new file mode 100644 index 00000000..38ce3b92 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs @@ -0,0 +1,30 @@ +using AcDream.Core.Items; + +namespace AcDream.Core.Tests.Items; + +public sealed class ItemUseabilityTests +{ + [Fact] + public void TargetedUse_hasHighTargetBits() + { + const uint healthKit = 0x000A0008u; // source contained, target self or contained + + Assert.True(ItemUseability.IsTargeted(healthKit)); + Assert.True(ItemUseability.AllowsSelfTarget(healthKit)); + Assert.Equal(ItemUseability.Contained, ItemUseability.SourceFlags(healthKit)); + Assert.Equal(ItemUseability.Self | ItemUseability.Contained, ItemUseability.TargetFlags(healthKit)); + } + + [Fact] + public void DirectContainedUse_isNotTargeted() + { + Assert.False(ItemUseability.IsTargeted(ItemUseability.Contained)); + Assert.True(ItemUseability.IsDirectUseable(ItemUseability.Contained)); + } + + [Fact] + public void UseableNo_isNotDirectUseable() + { + Assert.False(ItemUseability.IsDirectUseable(ItemUseability.No)); + } +} diff --git a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs index c29dd670..be6d931a 100644 --- a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs +++ b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs @@ -1,3 +1,4 @@ +using AcDream.Core.Items; using AcDream.Core.Player; namespace AcDream.Core.Tests.Player; @@ -244,4 +245,179 @@ public sealed class LocalPlayerStateTests // Now MaxApprox = 0 + 200 = 200; percent = 100/200 = 0.5. Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3); } + + [Fact] + public void OnProperties_ClonesBundleAndExposesInt64() + { + var s = new LocalPlayerState(); + var props = new PropertyBundle(); + props.Ints[0x19u] = 126; + props.Int64s[1u] = 1_234_567_890L; + + s.OnProperties(props); + props.Ints[0x19u] = 1; + props.Int64s[1u] = 2L; + + Assert.Equal(126, s.Properties.GetInt(0x19u)); + Assert.Equal(1_234_567_890L, s.Properties.GetInt64(1u)); + } + + [Fact] + public void OnSkillUpdate_StoresFormulaAdjustedCurrent() + { + var s = new LocalPlayerState(); + int changed = 0; + s.CharacterChanged += () => changed++; + + s.OnSkillUpdate( + skillId: 24u, + ranks: 12u, + status: 2u, + xp: 3456u, + init: 30u, + resistance: 0u, + lastUsed: 1.5, + formulaBonus: 80u); + + var run = s.GetSkill(24u); + Assert.NotNull(run); + Assert.Equal(122u, run!.Value.CurrentLevel); + Assert.Equal(2u, run.Value.Status); + Assert.Equal(3456u, run.Value.Xp); + Assert.Equal(1, changed); + } + + [Fact] + public void ApplySkillTraining_PromotesUntrainedSkillAndFiresCharacterChanged() + { + var s = new LocalPlayerState(); + int changed = 0; + s.CharacterChanged += () => changed++; + s.OnSkillUpdate( + skillId: 21u, + ranks: 0u, + status: 1u, + xp: 0u, + init: 5u, + resistance: 0u, + lastUsed: 0, + formulaBonus: 10u); + changed = 0; + + Assert.True(s.ApplySkillTraining(21u)); + + var skill = s.GetSkill(21u); + Assert.NotNull(skill); + Assert.Equal(2u, skill!.Value.Status); + Assert.Equal(1, changed); + } + + [Fact] + public void ApplySkillRaise_AddsRanksAndSpentXp() + { + var s = new LocalPlayerState(); + s.OnSkillUpdate( + skillId: 24u, + ranks: 12u, + status: 2u, + xp: 3456u, + init: 30u, + resistance: 0u, + lastUsed: 0, + formulaBonus: 80u); + + Assert.True(s.ApplySkillRaise(24u, amount: 10u, xpSpent: 500u)); + + var run = s.GetSkill(24u); + Assert.NotNull(run); + Assert.Equal(22u, run!.Value.Ranks); + Assert.Equal(3956u, run.Value.Xp); + Assert.Equal(132u, run.Value.CurrentLevel); + } + + [Fact] + public void ApplyAttributeRaise_AddsRanksAndSpentXp() + { + var s = new LocalPlayerState(); + s.OnAttributeUpdate(atType: 5u, ranks: 10u, start: 10u, xp: 100u); + + Assert.True(s.ApplyAttributeRaise(atType: 5u, amount: 1u, xpSpent: 25u)); + + var focus = s.GetAttribute(LocalPlayerState.AttributeKind.Focus); + Assert.NotNull(focus); + Assert.Equal(11u, focus!.Value.Ranks); + Assert.Equal(125u, focus.Value.Xp); + Assert.Equal(21u, focus.Value.Current); + } + + [Fact] + public void ApplyVitalRaise_AddsRanksAndSpentXpWithoutChangingCurrent() + { + var s = new LocalPlayerState(); + s.OnVitalUpdate(vitalId: 7u, ranks: 5u, start: 10u, xp: 100u, current: 12u); + + Assert.True(s.ApplyVitalRaise(vitalId: 1u, amount: 1u, xpSpent: 25u)); + + var health = s.Get(LocalPlayerState.VitalKind.Health); + Assert.NotNull(health); + Assert.Equal(6u, health!.Value.Ranks); + Assert.Equal(125u, health.Value.Xp); + Assert.Equal(12u, health.Value.Current); + } + + [Fact] + public void DebitIntProperty_Present_DebitsClampsAndFiresCharacterChanged() + { + var s = new LocalPlayerState(); + var props = new PropertyBundle(); + props.Ints[0x18u] = 3; + s.OnProperties(props); + int changed = 0; + s.CharacterChanged += () => changed++; + + Assert.True(s.DebitIntProperty(0x18u, 2)); + Assert.Equal(1, s.Properties.GetInt(0x18u)); + + Assert.True(s.DebitIntProperty(0x18u, 5)); // over-debit clamps at 0 + Assert.Equal(0, s.Properties.GetInt(0x18u)); + Assert.Equal(2, changed); + } + + [Fact] + public void DebitIntProperty_Absent_FalseAndNoEvent() + { + var s = new LocalPlayerState(); + int changed = 0; + s.CharacterChanged += () => changed++; + + Assert.False(s.DebitIntProperty(0x18u, 1)); + Assert.Equal(0, changed); + } + + [Fact] + public void DebitInt64Property_Present_DebitsAndFiresCharacterChanged() + { + var s = new LocalPlayerState(); + var props = new PropertyBundle(); + props.Int64s[2u] = 500L; + s.OnProperties(props); + int changed = 0; + s.CharacterChanged += () => changed++; + + Assert.True(s.DebitInt64Property(2u, 100L)); + Assert.Equal(400L, s.Properties.GetInt64(2u)); + Assert.Equal(1, changed); + } + + [Fact] + public void DebitInt64Property_ZeroOrAbsent_False() + { + var s = new LocalPlayerState(); + Assert.False(s.DebitInt64Property(2u, 100L)); // absent + + var props = new PropertyBundle(); + props.Int64s[2u] = 0L; + s.OnProperties(props); + Assert.False(s.DebitInt64Property(2u, 100L)); // already 0 — no negative banking + } }