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() {