From 05f62228654f5a977953dc83181043fd6e83e16f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Jul 2026 07:58:59 +0200 Subject: [PATCH] fix(ui): complete live targeted healing flow Route ACE server commands through the existing chat path, bind the retail paperdoll hit mask instead of its obscured viewport, and prefer authoritative private health vitals. Record the user-confirmed live gate and pin the production DAT widget type in tests. --- docs/plans/2026-04-11-roadmap.md | 2 +- ...026-07-10-retail-ui-fidelity-completion.md | 18 ++++++--- memory/project_ui_architecture.md | 35 ++++++++++++++-- .../UI/Layout/PaperdollController.cs | 37 ++++++++++++++++- src/AcDream.Core/Chat/WeenieErrorMessages.cs | 3 ++ .../Panels/Vitals/VitalsVM.cs | 20 +++++----- .../Layout/RetailFixtureConformanceTests.cs | 7 +++- .../UI/RetailUiInteractionFlowTests.cs | 40 +++++++++++++++++++ .../Chat/WeenieErrorMessagesTests.cs | 8 ++++ .../Panels/Chat/ChatCommandRouterTests.cs | 8 ++-- .../VitalsVMTests.cs | 27 +++++++++++++ 11 files changed, 181 insertions(+), 24 deletions(-) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index f7950953..916c55b7 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -484,7 +484,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅, **Wave 3.1 unified selection** (Core `SelectionState`; AP-58 retired) ✅, and **Wave 3.2 pure item-use/drop policy** (`ItemInteractionPolicy`; AP-99 retired) ✅ are shipped. Build order from here: **finish Wave 3 activation router/cursors, then D.5.3 selected-object mana + stack entry/slider and the separate favorite-spell bars; ISSUES #146/#147/#197.** Each ☐ below gets its own focused oracle/spec/plan/gate. - **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio [--layout 0xNNNN | --dump ] [--screenshot ]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed. - **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct. -- **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained end-to-end matrix green. +- **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained matrix and live ACE-provisioned healing gate user-confirmed. The 3-D doll routes through authored hit mask `0x100001D6` (resolved `UiButton` above viewport `0x100001D5`), and local health prefers authoritative private-vital state. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars. diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md index f38d8929..7eee8585 100644 --- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md +++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md @@ -363,11 +363,11 @@ consume the typed action seam when those surfaces land; until then the policy refuses unsafe wire sends rather than bypassing their gates. **Acceptance (2026-07-11):** Non-debug Release live-world gate passed for -ordinary item/container activation, equip/inventory synchronization, and grounded -world drop, with no observed UI regression. A healing kit was not available in -the test character's inventory, so the targeted-heal click was not exercised -live; the compatible/invalid/self target matrix remains pinned by Core and App -controller tests. Full suite green before launch: 4,575 passed, 5 skipped. +ordinary item/container activation, equip/inventory synchronization, grounded +world drop, and targeted healing. ACE command forwarding provisioned WCID 632 +with `/ci 632`; `/harmself` then proved the main-pack and paperdoll self-target +paths plus authoritative health-bar drain/recovery. Full suite green at the final +gate: 4,583 passed, 5 skipped. ### 3.3 Add one item primary-click chokepoint @@ -393,6 +393,14 @@ results. The consumed target is retained for retail's 500 ms double-click window so the second Click/DoubleClick pair cannot reactivate or equip the rejected object after target mode clears. Issue #197 and AP-107 are retired. +**Acceptance (2026-07-11):** User-confirmed the non-debug Release fallback +matrix for inactive target mode across inventory, paperdoll, toolbar, radar, +and world clicks, then the active targeted-heal flow through both the main pack +and 3-D paperdoll. The production DAT resolves the authored doll hit mask +`0x100001D6` as a `UiButton`; binding the viewport beneath it cannot receive a +live click. The mask now owns self-target clicks and hides with the viewport in +slot view. Full suite green at the final gate: 4,583 passed, 5 skipped. + ### 3.4 Complete retail cursor precedence and catalog - [ ] Port every reachable branch of `ClientUISystem::UpdateCursorState`, not only diff --git a/memory/project_ui_architecture.md b/memory/project_ui_architecture.md index f8e137fc..a4d39118 100644 --- a/memory/project_ui_architecture.md +++ b/memory/project_ui_architecture.md @@ -160,9 +160,9 @@ and preserve placement's action list separately from its boolean return (vendor sale and secure-trade attempts act while returning false). The 2026-07-11 non-debug Release gate passed ordinary use/container activation, -equip synchronization, and grounded world drop. No healing kit was available, -so targeted healing was not live-tested; retain the automated compatible, -invalid, and self-target coverage as the acceptance evidence for that branch. +equip synchronization, grounded world drop, and targeted healing. ACE `/ci 632` +provisioned a peerless kit and `/harmself` created a real health deficit; main-pack +and paperdoll self-targets both healed successfully. ## 2026-07-11 primary-click ownership @@ -176,3 +176,32 @@ to expose callbacks without importing controllers. The router retains a consumed target through the toolkit's 500 ms double-click window; otherwise UiRoot's second Click plus DoubleClick could run ordinary activation after the first click cleared target mode. + +The 2026-07-11 non-debug Release fallback gate passed for inventory, +paperdoll, toolbar, radar, and world primary clicks with target mode inactive, +and the active healing-kit target flow passed through the main pack and 3-D doll. +The production DAT resolves `0x100001D6`, the full-height paperdoll hit/drag mask, +as a `UiButton` over `UiViewport 0x100001D5`. Bind target behavior to the mask, +not only the obscured viewport, and hide/show both together with the Slots toggle. + +For local vitals, `PrivateUpdateVital`/`PrivateUpdateVitalCurrent` in +`LocalPlayerState` are authoritative. `VitalsVM.HealthPercent` must prefer that +private health snapshot over the world-facing `CombatState` percentage, using +the latter only before private state arrives. ACE `/harmself` exposed the stale +owner because it sends private vital deltas without changing that combat cache. + +## ACE command forwarding + +`ChatCommandRouter` is the single submit path for retained and developer chat. +Known client/chat verbs resolve locally; unknown letter verbs are server +commands. They are always published as `ChatChannelKind.Say`, regardless of the +selected channel, because commands must ride `GameAction Talk (0x0015)`. +`ChatInputParser` preserves `@verb` and canonicalizes `/verb` to `@verb` on the +wire: ACE's `GameActionTalk` checks only the at-sign form before invoking its +`CommandManager`. Do not add a parallel admin-command transport. + +For live test-item provisioning, ACE `@ci` accepts a weenie class id/classname +plus optional stack size, palette, and shade. Healing kit WCIDs are 628 crude, +629 plain, 630 good, 631 excellent, and 632 peerless. The `+Acdream` developer +character can therefore create the best kit with either `/ci 632` or `@ci 632`; +both forms must produce canonical `@ci 632` Talk text. diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index ac6b43f0..9e97291c 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -15,6 +15,9 @@ namespace AcDream.App.UI.Layout; /// public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelController { + public const uint DollViewportId = 0x100001D5u; + public const uint DollDragMaskId = 0x100001D6u; + // ── Slots-toggle public surface ─────────────────────────────────────────────────────────────── /// /// The 9 armor-slot element-ids whose Visible state the Slots button (0x100005BE) toggles. @@ -84,6 +87,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo private readonly PaperdollViewState _viewState = new(); private readonly List _armorSlots = new(); private UiElement? _dollViewport; // UiViewport wired in Slice 3; UiElement? keeps this task independent + private UiElement? _dollDragMask; private bool _disposed; private PaperdollController( @@ -137,13 +141,30 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo foreach (var id in ArmorSlotElementIds) if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor); - _dollViewport = layout.FindElement(0x100001D5u); // doll viewport (may be null until Slice 3) + _dollViewport = layout.FindElement(DollViewportId); // doll viewport (may be null until Slice 3) // The doll IS the player: clicking it during target-use self-targets // like the equip cells do. (The CURSOR over it stays the pending // four-arrows — retail resolves target cursors off the SmartBox world // object only; UI hover is always pending. 2026-07-03 visual gate.) + Action offerSelf = () => { _itemInteraction?.OfferSelfPrimaryClick(); }; if (_dollViewport is UiViewport doll) - doll.Clicked = () => { _itemInteraction?.OfferSelfPrimaryClick(); }; + doll.Clicked = offerSelf; + + // Retail's authored click/drag mask sits above the viewport and owns + // doll hit-testing (gmPaperDollUI::ListenToElementMessage). Binding only + // the viewport cannot win a live hit against this full-height overlay. + switch (layout.FindElement(DollDragMaskId)) + { + case UiButton dragMaskButton: + _dollDragMask = dragMaskButton; + dragMaskButton.OnClick = offerSelf; + break; + case UiDatElement dragMaskElement: + _dollDragMask = dragMaskElement; + dragMaskElement.ClickThrough = false; + dragMaskElement.OnClick = offerSelf; + break; + } var slotsBtnEl = layout.FindElement(0x100005BEu); if (slotsBtnEl is UiButton slotsBtn) @@ -266,6 +287,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo private void ApplyView() { if (_dollViewport is not null) _dollViewport.Visible = _viewState.DollVisible; + if (_dollDragMask is not null) _dollDragMask.Visible = _viewState.DollVisible; foreach (var a in _armorSlots) a.Visible = _viewState.ArmorSlotsVisible; } @@ -279,5 +301,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo _objects.ObjectRemoved -= OnObjectChanged; _objects.ObjectUpdated -= OnObjectChanged; _selection.Changed -= OnSelectionChanged; + if (_dollViewport is UiViewport doll) + doll.Clicked = null; + switch (_dollDragMask) + { + case UiButton button: + button.OnClick = null; + break; + case UiDatElement element: + element.OnClick = null; + break; + } } } diff --git a/src/AcDream.Core/Chat/WeenieErrorMessages.cs b/src/AcDream.Core/Chat/WeenieErrorMessages.cs index 8d3846ba..a64ad752 100644 --- a/src/AcDream.Core/Chat/WeenieErrorMessages.cs +++ b/src/AcDream.Core/Chat/WeenieErrorMessages.cs @@ -145,6 +145,9 @@ public static class WeenieErrorMessages [0x0053] = "You fail to affect _ because you are not the same sort of player killer as them!", [0x0054] = "You fail to affect _ because you are acting across a house boundary!", + // Healing + [0x04FF] = "_ is already at full health!", // _IsAtFullHealth + // Inventory / etiquette [0x001E] = "_ is too busy to accept gifts right now.", [0x002B] = "_ cannot carry anymore.", diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs index 5460defc..5dd8433d 100644 --- a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs @@ -4,17 +4,18 @@ using AcDream.Core.Player; namespace AcDream.UI.Abstractions.Panels.Vitals; /// -/// ViewModel for the vitals HUD panel. Reads live health percentage for the -/// local player from (which is fed by the server's -/// UpdateHealth (0x01C0) GameEvent). +/// ViewModel for the vitals HUD panel. Reads the local player's authoritative +/// private vital snapshot, falling back to world-combat health before that +/// snapshot is available. /// /// /// Sources: /// /// /// -/// HP — (percent-only, -/// updated by UpdateHealth (0x01C0)). +/// HP — from +/// PrivateUpdateVital(Current), with +/// as fallback. /// Stamina / Mana — 's /// StaminaPercent / ManaPercent, populated from the /// CreatureProfile embedded in PlayerDescription @@ -60,11 +61,12 @@ public sealed class VitalsVM public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid; /// - /// Current health percent (0..1) for the local player. Returns 1.0 - /// before login or if the server has never sent an UpdateHealth for - /// this GUID. + /// Current health percent (0..1) for the local player. The private vital + /// is authoritative for self; world-combat percentage is the pre-login + /// and legacy fallback. /// - public float HealthPercent => _combat.GetHealthPercent(_localPlayerGuid); + public float HealthPercent + => _local?.HealthPercent ?? _combat.GetHealthPercent(_localPlayerGuid); /// /// Stamina percent (0..1), or null when no diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailFixtureConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailFixtureConformanceTests.cs index d64ce212..e5be862d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailFixtureConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailFixtureConformanceTests.cs @@ -1,3 +1,4 @@ +using AcDream.App.UI; using AcDream.App.UI.Layout; namespace AcDream.App.Tests.UI.Layout; @@ -50,7 +51,11 @@ public sealed class RetailFixtureConformanceTests Assert.NotNull(Find(root, InventoryController.ContentsGridId)); Assert.NotNull(Find(root, InventoryController.ContainerListId)); Assert.NotNull(Find(root, InventoryController.BurdenMeterId)); - Assert.NotNull(Find(root, 0x100001D5u)); // mounted paperdoll viewport + Assert.NotNull(Find(root, PaperdollController.DollViewportId)); + Assert.NotNull(Find(root, PaperdollController.DollDragMaskId)); + + var layout = FixtureLoader.LoadInventory(); + Assert.IsType(layout.FindElement(PaperdollController.DollDragMaskId)); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs index 64b7d2ad..ace738a9 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs @@ -73,6 +73,25 @@ public sealed class RetailUiInteractionFlowTests Width = 56, Height = 20, }; + var dollViewport = new UiViewport + { + DatElementId = PaperdollController.DollViewportId, + Left = 270, + Top = 80, + Width = 70, + Height = 90, + }; + var dollDragMask = new UiButton( + new ElementInfo { Id = PaperdollController.DollDragMaskId, Type = 1 }, + static _ => (0u, 0, 0)) + { + DatElementId = PaperdollController.DollDragMaskId, + Left = 270, + Top = 80, + Width = 70, + Height = 90, + ZOrder = 1, + }; var meter = new UiMeter { DatElementId = InventoryController.BurdenMeterId, @@ -107,6 +126,8 @@ public sealed class RetailUiInteractionFlowTests [InventoryController.ContentsScrollbarId] = scrollbar, [ChestArmorSlotId] = chestArmor, [SlotsButtonId] = slotsButton, + [PaperdollController.DollViewportId] = dollViewport, + [PaperdollController.DollDragMaskId] = dollDragMask, }; layoutRoot.AddChild(grid); @@ -114,6 +135,8 @@ public sealed class RetailUiInteractionFlowTests layoutRoot.AddChild(mainPack); layoutRoot.AddChild(chestArmor); layoutRoot.AddChild(slotsButton); + layoutRoot.AddChild(dollViewport); + layoutRoot.AddChild(dollDragMask); layoutRoot.AddChild(meter); layoutRoot.AddChild(titleText); layoutRoot.AddChild(burdenText); @@ -260,6 +283,23 @@ public sealed class RetailUiInteractionFlowTests Assert.False(interaction.IsTargetModeActive); } + [Fact] + public void InventoryTargetUse_thenPaperdollBodyClick_selfTargetsThroughUiRoot() + { + var h = new Harness(); + h.SeedHealthKit(); + var interaction = h.BindInventoryInteraction(); + h.BindPaperdoll(interaction); + var probe = h.Probe(); + + Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory)); + Assert.True(interaction.IsTargetModeActive); + Assert.True(probe.ClickElement(PaperdollController.DollDragMaskId)); + + Assert.Equal(new[] { (HealthKit, Player) }, h.UseWithTarget); + Assert.False(interaction.IsTargetModeActive); + } + [Fact] public void InventoryTargetUse_invalidInventoryItem_isConsumedWithoutSelectionFallback() { diff --git a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs index 1c49f4c6..fbc7de8f 100644 --- a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs +++ b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs @@ -136,4 +136,12 @@ public sealed class WeenieErrorMessagesTests "You fail to affect Drudge because they cannot be harmed!", WeenieErrorMessages.Format(0x004F, "Drudge")); } + + [Fact] + public void Format_HealingTargetAlreadyFull_SubstitutesParam() + { + Assert.Equal( + "+Acdream is already at full health!", + WeenieErrorMessages.Format(0x04FF, "+Acdream")); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs index bd180c7e..5707e1e7 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -68,14 +68,16 @@ public class ChatCommandRouterTests Assert.DoesNotContain(log.Snapshot(), e => e.Text.Contains("Unknown command")); } - [Fact] - public void ServerCommandWithArgs_PublishesAtFormAsSay_EvenOnChannelDefault() + [Theory] + [InlineData("/ci 629 5")] + [InlineData("@ci 629 5")] + public void ServerCommandWithArgs_PublishesCanonicalAtFormAsSay_EvenOnChannelDefault(string command) { // Commands resolve before channel routing (retail behavior): // even with Fellowship as the active input channel, a command // goes out as Say/Talk — the only wire action ACE parses @ on. var (vm, _, bus) = Fixture(); - var outcome = ChatCommandRouter.Submit("/ci 629 5", vm, bus, ChatChannelKind.Fellowship); + var outcome = ChatCommandRouter.Submit(command, vm, bus, ChatChannelKind.Fellowship); Assert.Equal(SubmitOutcome.Sent, outcome); Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); Assert.Equal("@ci 629 5", bus.Last.Text); diff --git a/tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs index 35200882..5f158724 100644 --- a/tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs @@ -19,6 +19,33 @@ public sealed class VitalsVMTests Assert.Equal(0.42f, vm.HealthPercent, precision: 3); } + [Fact] + public void HealthPercent_LocalPrivateVitalOverridesStaleCombatPercent() + { + var combat = new CombatState(); + uint guid = 0x5000_0042u; + combat.OnUpdateHealth(guid, 1f); + + var local = new LocalPlayerState(); + local.OnVitalUpdate(vitalId: 2u, ranks: 50u, start: 50u, xp: 0u, current: 1u); + var vm = new VitalsVM(combat, local); + vm.SetLocalPlayerGuid(guid); + + Assert.Equal(0.01f, vm.HealthPercent, precision: 3); + } + + [Fact] + public void HealthPercent_ReadsCurrentOnlyPrivateVitalDeltaWithoutStaleCache() + { + var local = new LocalPlayerState(); + local.OnVitalUpdate(vitalId: 2u, ranks: 50u, start: 50u, xp: 0u, current: 100u); + var vm = new VitalsVM(new CombatState(), local); + + local.OnVitalCurrent(vitalId: 2u, current: 25u); + + Assert.Equal(0.25f, vm.HealthPercent, precision: 3); + } + [Fact] public void HealthPercent_ReturnsOne_WhenGuidUnknown() {