docs(D.5.3a): spec + plan — selected-object meter (Stream A)
Brainstormed design for the action bar's bottom strip: name + Health meter on selection (mana deferred #140). Decisions: SelectionChanged via property setter; send QueryHealth(0x01BF) on select. Grounded in retail gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198635) — clear-then-populate, overlay state 0x1000000b, health gate IsPlayer||pet||attackable. Render-bug fix is BuildMeter-only (single-image back+fill meter; UiMeter already renders it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d400bc6105
commit
e8562fc4e2
2 changed files with 335 additions and 0 deletions
|
|
@ -0,0 +1,46 @@
|
|||
# D.5.3a — Selected-object meter — implementation plan
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md`.
|
||||
Pre-approved by user 2026-06-18; subagent-driven, sequential (build-safe in one worktree).
|
||||
|
||||
Mandatory per task: cite named-retail anchors in comments; `dotnet build` + the relevant
|
||||
`dotnet test` green; match surrounding code style. No commits by subagents — the lead commits the
|
||||
coherent set after the full build+test passes.
|
||||
|
||||
## Task order (each builds on the accumulated working tree)
|
||||
|
||||
### T1 — `WorldSession.SendQueryHealth` (+ net test) · project: `AcDream.Core.Net`
|
||||
- Add `SendQueryHealth(uint targetGuid)` mirroring `SendChangeCombatMode` (`WorldSession.cs:1134`):
|
||||
`NextGameActionSequence()` → `SocialActions.BuildQueryHealth(seq, guid)` → `SendGameAction(body)`.
|
||||
- Test in `tests/AcDream.Core.Net.Tests/`: drive it through the existing send-capture seam used by the
|
||||
other `WorldSession.Send*` tests; assert captured bytes == `BuildQueryHealth(seq, guid)`.
|
||||
- Accept: `dotnet test` for `AcDream.Core.Net.Tests` green.
|
||||
|
||||
### T2 — `DatWidgetFactory.BuildMeter` single-image shape (+ test) · project: `AcDream.App`
|
||||
- Handle `containers.Count == 1`: `BackLeft = info.StateMedia[""].File`,
|
||||
`FrontLeft = containers[0].StateMedia[""].File`, tile/right = 0. Keep `>= 2` (vitals) path unchanged.
|
||||
Warn only on `Count == 0` / `Count > 2`.
|
||||
- Extend `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`: 1-container synthetic meter
|
||||
asserts Back/Front populated + others 0; 2-container case asserts vitals path unchanged.
|
||||
- Accept: `dotnet test` for `AcDream.App.Tests` green.
|
||||
|
||||
### T3 — `SelectedObjectController` (+ test) · project: `AcDream.App`
|
||||
- New `src/AcDream.App/UI/Layout/SelectedObjectController.cs` per spec §3 (Bind signature, bind-time
|
||||
setup, `OnSelectionChanged` clear-then-populate). Cite `HandleSelectionChanged:198635`.
|
||||
- New `tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs` per spec §Testing item 2
|
||||
(mirror `ToolbarControllerTests` for building a minimal `ImportedLayout` + recording delegates).
|
||||
- Accept: `dotnet test` for `AcDream.App.Tests` green.
|
||||
|
||||
### T4 — GameWindow integration + register rows · project: `AcDream.App` (depends on T1, T3)
|
||||
- Convert `_selectedGuid` field → `SelectedGuid` property + `SelectionChanged` event (spec §1); replace
|
||||
the 3 write sites; leave read sites on the field.
|
||||
- Remove `0x100001A1` + `0x100001A2` from `ToolbarController.HiddenIds` (keep `0x100001A4`).
|
||||
- Wire `SelectedObjectController.Bind(...)` after `ToolbarController.Bind` (spec §5).
|
||||
- Add the 2 divergence rows (spec §Divergence) to
|
||||
`docs/architecture/retail-divergence-register.md`.
|
||||
- Accept: full `dotnet build` + `dotnet test` green.
|
||||
|
||||
## Then (lead)
|
||||
- Adversarial Opus review of the full diff vs spec + decomp.
|
||||
- Commit the coherent set to the branch; update roadmap/ISSUES if applicable; memory if a durable lesson.
|
||||
- Stop for the user's visual gate (the acceptance test for this stream).
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
# D.5.3a — Selected-object meter (Stream A) — design
|
||||
|
||||
**Date:** 2026-06-18
|
||||
**Phase:** D.5.3a (the action bar's bottom strip). Roadmap: D.2b retail-UI track, issue #140.
|
||||
**Branch:** `claude/hopeful-maxwell-214a12`.
|
||||
**Handoff parent:** `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §2.
|
||||
|
||||
## Goal
|
||||
|
||||
When the player selects a world object (LMB pick → `PickAndStoreSelection`, or Tab/Q combat-target
|
||||
→ `SelectClosestCombatTarget`), the action bar's bottom strip shows:
|
||||
|
||||
- the selected object's **name** (always, for any selection), and
|
||||
- a live **Health** meter — only for targets that are a player, a pet, or attackable
|
||||
(retail's `IsPlayer() || pet_owner || ObjectIsAttackable()` gate).
|
||||
|
||||
On deselect (or despawn of the selected object) the strip clears.
|
||||
|
||||
**Out of scope (deferred):** the **Mana** meter (`0x100001A2`, issue #140 — owned-item-only),
|
||||
the stack-size entry box + slider (`0x100001A3`/`0x100001A4`), and the formatted stack-count name
|
||||
suffix. Mana is a tracked feature gap, not a runtime deviation.
|
||||
|
||||
## Retail oracle
|
||||
|
||||
`gmToolbarUI::HandleSelectionChanged` — `docs/research/named-retail/acclient_2013_pseudo_c.txt:198635`.
|
||||
Verbatim behavior (the spec follows this exactly):
|
||||
|
||||
1. **Clear-then-populate.** On any selection change (`m_iidSelectedObject != selectedID`):
|
||||
- `UIElement_Text::SetText(m_pSelObjectName, "")` — clear the name.
|
||||
- `m_pSelObjectField->SetState(0)` — reset the overlay field (`0x100001A0`) to its blank
|
||||
DirectState.
|
||||
- If the health meter was visible: `Event_QueryHealth(0)` (cancel) + `SetVisible(0)`.
|
||||
- If the mana meter was visible: `Event_QueryItemMana(0)` + `SetVisible(0)`.
|
||||
- Hide stack entry box + slider.
|
||||
2. **Selection == 0** → set the use-object button to disabled state and return (strip stays cleared).
|
||||
3. **Selection != 0** (weenie object resolved):
|
||||
- Name = `ACCWeenieObject::GetObjectName(NAME_APPROPRIATE)`. `_stackSize <= 1` → plain name;
|
||||
`_stackSize > 1` → formatted with count (**deferred**).
|
||||
- For a non-stack (`_stackSize == 0 || _stackSize <= 1`):
|
||||
- `eax_29 = IsPlayer()`; if not player and no `pet_owner`, `eax_32 = ObjectIsAttackable(selectedID)`.
|
||||
- **If `IsPlayer || pet_owner != 0 || attackable`:** `m_pSelObjectField->SetState(0x1000000b)`
|
||||
(the "ObjectSelected" state) **and** `CM_Combat::Event_QueryHealth(m_iidSelectedObject)`.
|
||||
(Health meter becomes visible via the subsequent `RecvNotice_UpdateObjectHealth` path,
|
||||
which `SetVisible(1)`s it and sets the fill — see handoff §2.)
|
||||
- **Else:** `m_pSelObjectField->SetState(0x1000000b)`; if `IsOwnedByPlayer`,
|
||||
`CM_Item::Event_QueryItemMana(selectedID)` (**mana deferred**).
|
||||
|
||||
Supporting anchors: `RecvNotice_UpdateObjectHealth` (`:196213`) → `SetAttribute_Float(meter, 0x69, pct)`
|
||||
(property `0x69` = fill ratio); `UIElement_Meter::Initialize` (`:123328`), `OnSetAttribute` (`:123712`).
|
||||
|
||||
State/sprite ids (from `.layout-dumps/toolbar-0x21000016.txt`): the overlay field `0x100001A0`
|
||||
carries states **ObjectSelected** (id `0x1000000b`, sprite `0x06001937`) and **StackedItemSelected**
|
||||
(sprite `0x06004CF4`); health meter `0x100001A1` back-track DirectState `0x0600193E`, fill child
|
||||
`0x00000002` DirectState `0x0600193F`; mana meter `0x100001A2` back `0x060022D5` / fill `0x060022D6`.
|
||||
|
||||
## Current-code facts (verified at HEAD)
|
||||
|
||||
- **Selection state** is a private field `_selectedGuid` (`GameWindow.cs` ~`:848`), assigned at 3 sites:
|
||||
`PickAndStoreSelection` (~`:11571`), `SelectClosestCombatTarget` (~`:11961`), and the despawn-clear
|
||||
(`if (_selectedGuid == serverGuid) _selectedGuid = null;` ~`:3710`). No change event exists.
|
||||
`TargetIndicatorPanel` polls it via `selectedGuidProvider: () => _selectedGuid`.
|
||||
- **`CombatState`** (`AcDream.Core.Combat`) has `GetHealthPercent(guid)` (returns `1f` if unseen) and
|
||||
`HealthChanged`. `UpdateHealth (0x01C0)` → `OnUpdateHealth` is already wired (`GameEventWiring`).
|
||||
- **`SocialActions.BuildQueryHealth(uint seq, uint targetGuid)`** exists (opcode `0x01BF`, replies
|
||||
`UpdateHealth 0x01C0`). No `WorldSession.SendQueryHealth` wrapper yet.
|
||||
- **`IsLiveCreatureTarget(uint guid)`** (`GameWindow.cs` ~`:11979`): not-self + in-world +
|
||||
`ItemType.Creature` flag. Used to gate Tab/Q targeting and `UseItemByGuid`.
|
||||
- **`VitalsController.Bind`** is the proven bind pattern: find meter by id, set `m.Fill = () => pct()`
|
||||
(polled each draw), attach a centered `UiText` child (dat font, `ClickThrough`) for text.
|
||||
- **`UiMeter.DrawHBar`** already renders a *single full-width sprite* correctly: with `tile`/`right`
|
||||
ids = 0, the left-cap spans the whole bar and the fill UV-crops to the fraction. **No `UiMeter`
|
||||
change is needed** for the single-image toolbar meters.
|
||||
- **`DatWidgetFactory.BuildMeter`** assumes **2** Type-3 slice containers (vitals 3-slice). The toolbar
|
||||
selected-object meters have **1** Type-3 child (the fill, on its own DirectState) with the back-track
|
||||
on the *meter element's own* DirectState → the `containers.Count != 2` branch mishandles them.
|
||||
- **`UiDatElement.ActiveState`** (string) drives `ActiveMedia()`; `""` = blank DirectState. This is the
|
||||
overlay-state switch for `0x100001A0`.
|
||||
- **`ClientObject`** exposes `Name` and `StackSize`. `ClientObjectTable.Get(guid)` returns the object
|
||||
(or null). `ToolbarController` already binds with `Objects` (the `ClientObjectTable`).
|
||||
- **`ToolbarController.HiddenIds`** currently hides `0x100001A1` (health), `0x100001A2` (mana),
|
||||
`0x100001A4` (stack slider) at bind.
|
||||
|
||||
## Decisions (settled in brainstorm)
|
||||
|
||||
- **Selection signal: event via property setter.** Convert `_selectedGuid` → a `SelectedGuid` property
|
||||
whose setter fires `event Action<uint?>? SelectionChanged` only when the value actually changes.
|
||||
Replace the 3 assignment sites with the property; reads unchanged. (Retail-faithful — selection is
|
||||
event-driven; the setter centralizes the fire and auto-dedups.)
|
||||
- **Send `QueryHealth (0x01BF)` on select** for health-bearing targets (retail-faithful; builder
|
||||
exists). Continuous updates still come from server `UpdateHealth` broadcasts.
|
||||
- **Mana deferred** (issue #140).
|
||||
|
||||
## Architecture
|
||||
|
||||
Three new units + one refactor + one wiring change. Each unit is independently testable.
|
||||
|
||||
### 1. `GameWindow.SelectedGuid` property + `SelectionChanged` event (refactor)
|
||||
|
||||
```csharp
|
||||
public event Action<uint?>? SelectionChanged;
|
||||
private uint? _selectedGuid;
|
||||
private uint? SelectedGuid
|
||||
{
|
||||
get => _selectedGuid;
|
||||
set
|
||||
{
|
||||
if (_selectedGuid == value) return; // dedup: fire only on real change
|
||||
_selectedGuid = value;
|
||||
SelectionChanged?.Invoke(value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace the 3 *write* sites (`_selectedGuid = …`) with `SelectedGuid = …`. Leave all *read* sites
|
||||
(`_selectedGuid is uint`, `() => _selectedGuid`, the despawn comparison's read half) on the field —
|
||||
they observe the same backing store. The despawn-clear becomes
|
||||
`if (_selectedGuid == serverGuid) SelectedGuid = null;`.
|
||||
|
||||
### 2. `DatWidgetFactory.BuildMeter` — handle the single-image meter shape
|
||||
|
||||
After ordering the Type-3 child containers by `ReadOrder`:
|
||||
|
||||
- **`containers.Count >= 2`** (vitals): unchanged — `SliceIds(containers[0])` → Back\*,
|
||||
`SliceIds(containers[1])` → Front\*.
|
||||
- **`containers.Count == 1`** (toolbar selected-object meter): single-image back+fill.
|
||||
- `m.BackLeft = info.StateMedia[""].File` (the meter element's own DirectState back-track),
|
||||
`BackTile = BackRight = 0`.
|
||||
- `m.FrontLeft = containers[0].StateMedia[""].File` (the fill child's own DirectState),
|
||||
`FrontTile = FrontRight = 0`.
|
||||
- The fill child has **no** image grandchildren, so `SliceIds` must **not** be used for it; read the
|
||||
container's own `StateMedia[""]` directly.
|
||||
- **`containers.Count == 0`**: leave the warning (genuinely malformed).
|
||||
|
||||
Keep a `Console.WriteLine` only for the genuinely-unexpected `Count == 0` (or `> 2`) case; the
|
||||
`Count == 1` case is now a handled shape, not a warning.
|
||||
|
||||
`UiMeter` is unchanged — `DrawHBar(BackLeft=fullSprite,0,0,clipW=Width)` draws the back once,
|
||||
`DrawHBar(FrontLeft=fullSprite,0,0,clipW=Width*p)` UV-crops the fill to the health fraction.
|
||||
|
||||
### 3. `SelectedObjectController` (new — `src/AcDream.App/UI/Layout/SelectedObjectController.cs`)
|
||||
|
||||
The `HandleSelectionChanged` analogue. A sealed class (like `ToolbarController`) bound once.
|
||||
|
||||
**Element ids** (constants): name `0x1000019F`, overlay field `0x100001A0`, health meter `0x100001A1`.
|
||||
(`0x100001A2` mana / `0x100001A3`/`0x100001A4` stack are not touched here — deferred.)
|
||||
|
||||
**`Bind` signature:**
|
||||
|
||||
```csharp
|
||||
public static SelectedObjectController Bind(
|
||||
ImportedLayout layout,
|
||||
Action<Action<uint?>> subscribeSelectionChanged, // hands the controller its handler to register
|
||||
Func<uint, bool> isHealthTarget, // IsLiveCreatureTarget proxy
|
||||
Func<uint, string?> name, // ClientObjectTable.Get(g)?.Name
|
||||
Func<uint, float> healthPercent, // CombatState.GetHealthPercent
|
||||
Func<uint, uint> stackSize, // ClientObjectTable.Get(g)?.StackSize ?? 0 (overlay state)
|
||||
Action<uint> sendQueryHealth, // WorldSession.SendQueryHealth (no-op if offline)
|
||||
UiDatFont? datFont)
|
||||
```
|
||||
|
||||
`subscribeSelectionChanged` is invoked once with the controller's `OnSelectionChanged` handler so the
|
||||
host can do `c => SelectionChanged += c` without the controller referencing `GameWindow`. (Keeps the
|
||||
Core-clean delegate-seam style of `TargetIndicatorPanel`.)
|
||||
|
||||
**Bind-time setup:**
|
||||
- Find the three elements (silently skip any that are absent — partial/test layouts).
|
||||
- `_healthMeter.Visible = false` (this controller now **owns** the meter's initial-hidden state).
|
||||
- Attach a centered `UiText` child to the name element (mirror `VitalsController.BindMeter`'s number
|
||||
attach): `Centered`, `DatFont = datFont`, `ClickThrough`, `AcceptsFocus=false`, `IsEditControl=false`,
|
||||
`CapturesPointerDrag=false`, anchored to fill the parent, `LinesProvider = () =>` the current name as
|
||||
a single white line (empty → no lines). Color: white for D.5.3a (`new Vector4(1,1,1,1)`).
|
||||
- `_healthMeter.Fill = () => _current is uint g ? healthPercent(g) : 0f` (polled each draw).
|
||||
- Register the handler via `subscribeSelectionChanged(OnSelectionChanged)`.
|
||||
|
||||
**`OnSelectionChanged(uint? guid)`** (mirrors the decomp's clear-then-populate):
|
||||
- **Clear first:** `_healthMeter.Visible = false`; overlay `ActiveState = ""`; `_currentName = null`.
|
||||
- Set `_current = guid`.
|
||||
- If `guid is null` → done (strip cleared).
|
||||
- Else:
|
||||
- `_currentName = name(guid)` (the name `UiText` reads this).
|
||||
- overlay `ActiveState = stackSize(guid) > 1 ? "StackedItemSelected" : "ObjectSelected"`.
|
||||
- If `isHealthTarget(guid)`: `_healthMeter.Visible = true`; `sendQueryHealth(guid)`.
|
||||
- (else: name + overlay only — friendly NPC / non-owned item / scenery.)
|
||||
|
||||
State held: `_current` (uint?), `_currentName` (string?). The meter `Fill` + name `LinesProvider`
|
||||
read these closures, so the per-frame draw reflects live data without a tick.
|
||||
|
||||
> **Note on the meter-visible timing.** Retail makes the health meter visible from
|
||||
> `RecvNotice_UpdateObjectHealth` (when the queried value arrives), not from
|
||||
> `HandleSelectionChanged` itself. acdream shows it immediately on select for a health target (the
|
||||
> fill polls `GetHealthPercent`, which is `1.0` until the `QueryHealth` reply lands a beat later).
|
||||
> This avoids a one-round-trip blank-then-pop and is visually indistinguishable for a full-HP target;
|
||||
> for a damaged target the bar corrects within one server round-trip. Recorded as a divergence row.
|
||||
|
||||
### 4. `WorldSession.SendQueryHealth(uint targetGuid)` (new)
|
||||
|
||||
```csharp
|
||||
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
|
||||
public void SendQueryHealth(uint targetGuid)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
byte[] body = SocialActions.BuildQueryHealth(seq, targetGuid);
|
||||
SendGameAction(body);
|
||||
}
|
||||
```
|
||||
|
||||
(Pattern = `SendChangeCombatMode`, `WorldSession.cs:1134`.)
|
||||
|
||||
### 5. GameWindow wiring (minimal)
|
||||
|
||||
After `ToolbarController.Bind` (the toolbar layout is in scope as `toolbarLayout`, dat font as
|
||||
`vitalsDatFont`):
|
||||
|
||||
```csharp
|
||||
AcDream.App.UI.Layout.SelectedObjectController.Bind(
|
||||
toolbarLayout,
|
||||
subscribeSelectionChanged: h => SelectionChanged += h,
|
||||
isHealthTarget: IsLiveCreatureTarget,
|
||||
name: g => Objects.Get(g)?.Name,
|
||||
healthPercent: g => Combat.GetHealthPercent(g),
|
||||
stackSize: g => Objects.Get(g)?.StackSize ?? 0u,
|
||||
sendQueryHealth: g => _liveSession?.SendQueryHealth(g),
|
||||
datFont: vitalsDatFont);
|
||||
```
|
||||
|
||||
Also: remove `0x100001A1` and `0x100001A2` from `ToolbarController.HiddenIds` (single-owner: the
|
||||
selected-object meters are now owned by `SelectedObjectController`); `0x100001A4` (stack slider) stays
|
||||
in `HiddenIds` (deferred). Convert the `_selectedGuid` field → the `SelectedGuid` property (unit 1).
|
||||
|
||||
## Data flow
|
||||
|
||||
select → `SelectedGuid` setter → `SelectionChanged(guid)` → `SelectedObjectController.OnSelectionChanged`
|
||||
→ name + overlay set, meter shown (health target), `SendQueryHealth(guid)` → server `UpdateHealth 0x01C0`
|
||||
→ `GameEventWiring` → `CombatState.OnUpdateHealth` → cache → meter `Fill` poll reads
|
||||
`GetHealthPercent` → bar fills. Deselect / despawn → `SelectionChanged(null)` → strip cleared.
|
||||
|
||||
## Error handling / edge cases
|
||||
|
||||
- **Unknown guid** → `GetHealthPercent` returns `1.0` (full) until the `QueryHealth` reply arrives.
|
||||
- **Selected entity despawns** → existing despawn-clear sets `SelectedGuid = null` → `SelectionChanged(null)`.
|
||||
- **Partial / test layout** (missing elements) → controller silently skips absent elements
|
||||
(`VitalsController` pattern).
|
||||
- **No live session** → `_liveSession?.SendQueryHealth` no-ops.
|
||||
- **Re-select the same guid** → property setter dedups; no redundant query / re-show.
|
||||
|
||||
## Testing (conformance)
|
||||
|
||||
All App-layer tests in `tests/AcDream.App.Tests/`; net test in `tests/AcDream.Core.Net.Tests/`.
|
||||
|
||||
1. **`DatWidgetFactoryTests`** (extend): feed a synthetic 1-container meter `ElementInfo` (back on the
|
||||
element's `StateMedia[""]`, fill on the single Type-3 child's `StateMedia[""]`) → assert
|
||||
`BackLeft == backFile`, `FrontLeft == fillFile`, `BackTile/BackRight/FrontTile/FrontRight == 0`,
|
||||
and no warning path taken. Add/keep a 2-container case asserting the vitals 3-slice path is
|
||||
unchanged.
|
||||
2. **`SelectedObjectControllerTests`** (new — mirror `ToolbarControllerTests`): build a minimal
|
||||
`ImportedLayout` containing `0x1000019F`/`0x100001A0` (as `UiDatElement`)/`0x100001A1` (as
|
||||
`UiMeter`). Use recording delegates. Assert:
|
||||
- bind → health meter `Visible == false`, a name `UiText` child attached.
|
||||
- select health target → meter `Visible == true`, overlay `ActiveState == "ObjectSelected"`, name
|
||||
provider returns the object name, `sendQueryHealth` invoked exactly once with the guid.
|
||||
- select stack (`stackSize > 1`) → overlay `ActiveState == "StackedItemSelected"`.
|
||||
- select non-health target → meter stays hidden, name set, `sendQueryHealth` **not** invoked.
|
||||
- deselect (`null`) → meter hidden, overlay `ActiveState == ""`, name provider returns empty.
|
||||
- re-fire same guid path is driven by the event, so the dedup is the property's job (covered in 3).
|
||||
3. **`SendQueryHealth`** (net test): drive `WorldSession.SendQueryHealth(guid)` through the existing
|
||||
send-capture seam (the same harness `SendChangeCombatMode` / chat sends use) and assert the captured
|
||||
GameAction bytes equal `SocialActions.BuildQueryHealth(seq, guid)`.
|
||||
4. **`SelectedGuid` dedup**: the property is on `GameWindow` (not unit-testable in isolation). Its
|
||||
contract — "fires once on change, never on same value, fires `null` on clear" — is asserted
|
||||
indirectly by test 2's reliance on single-fire and confirmed at the visual gate. No standalone test.
|
||||
|
||||
## Divergence register rows (add in the implementation commit)
|
||||
|
||||
- **Health-meter gate approximation.** Retail shows the health meter for
|
||||
`IsPlayer() || pet_owner || ObjectIsAttackable()`; acdream uses `IsLiveCreatureTarget`
|
||||
(the `ItemType.Creature` flag). Risk: a friendly (non-attackable) NPC shows a health meter where
|
||||
retail would show name+overlay only. Cite `SelectedObjectController` + `HandleSelectionChanged:198754`.
|
||||
- **Meter-visible timing.** acdream shows the health meter on select; retail shows it from
|
||||
`RecvNotice_UpdateObjectHealth` when the queried value arrives. Risk: a freshly-selected
|
||||
off-screen-damaged target reads full for one server round-trip. Cite
|
||||
`SelectedObjectController.OnSelectionChanged` + `HandleSelectionChanged:198757`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `dotnet build` green; `dotnet test` green (new + existing).
|
||||
- Every AC-specific behavior cites its named-retail anchor in comments.
|
||||
- Divergence rows added.
|
||||
- Visual gate (user): selecting a creature shows its name + a correct HP bar; deselecting clears the
|
||||
strip; selecting a non-creature object shows the name only.
|
||||
Loading…
Add table
Add a link
Reference in a new issue