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.
This commit is contained in:
Erik 2026-07-11 07:58:59 +02:00
parent eb6229394a
commit 05f6222865
11 changed files with 181 additions and 24 deletions

View file

@ -484,7 +484,7 @@ behavior. Estimated 1726 days focused work, 35 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 <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` 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.

View file

@ -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

View file

@ -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.

View file

@ -15,6 +15,9 @@ namespace AcDream.App.UI.Layout;
/// </summary>
public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelController
{
public const uint DollViewportId = 0x100001D5u;
public const uint DollDragMaskId = 0x100001D6u;
// ── Slots-toggle public surface ───────────────────────────────────────────────────────────────
/// <summary>
/// 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<UiItemList> _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;
}
}
}

View file

@ -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.",

View file

@ -4,17 +4,18 @@ using AcDream.Core.Player;
namespace AcDream.UI.Abstractions.Panels.Vitals;
/// <summary>
/// ViewModel for the vitals HUD panel. Reads live health percentage for the
/// local player from <see cref="CombatState"/> (which is fed by the server's
/// <c>UpdateHealth (0x01C0)</c> 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.
///
/// <para>
/// <b>Sources:</b>
/// </para>
///
/// <list type="bullet">
/// <item>HP — <see cref="CombatState.GetHealthPercent"/> (percent-only,
/// updated by <c>UpdateHealth (0x01C0)</c>).</item>
/// <item>HP — <see cref="LocalPlayerState.HealthPercent"/> from
/// <c>PrivateUpdateVital(Current)</c>, with
/// <see cref="CombatState.GetHealthPercent"/> as fallback.</item>
/// <item>Stamina / Mana — <see cref="LocalPlayerState"/>'s
/// <c>StaminaPercent</c> / <c>ManaPercent</c>, populated from the
/// <c>CreatureProfile</c> embedded in <c>PlayerDescription
@ -60,11 +61,12 @@ public sealed class VitalsVM
public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid;
/// <summary>
/// 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.
/// </summary>
public float HealthPercent => _combat.GetHealthPercent(_localPlayerGuid);
public float HealthPercent
=> _local?.HealthPercent ?? _combat.GetHealthPercent(_localPlayerGuid);
/// <summary>
/// Stamina percent (0..1), or <c>null</c> when no

View file

@ -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<UiButton>(layout.FindElement(PaperdollController.DollDragMaskId));
}
[Fact]

View file

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

View file

@ -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"));
}
}

View file

@ -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);

View file

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