feat(ui): port retail selected-item mana

Parse and route the complete 0x0264 guid/fraction/valid response, send exact 0x0263 item-mana queries, and reproduce retail meter visibility plus guid-zero cancellation for mana and health selection changes. Keep the behavior in Core state and the retained selected-object controller with wire/state/UI conformance coverage.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 11:16:21 +02:00
parent df460f94c3
commit fa1da02783
21 changed files with 402 additions and 38 deletions

View file

@ -60,6 +60,10 @@ public class SelectedObjectControllerTests
dict[SelectedObjectController.HealthMeterId] = healthMeterEl;
root.AddChild(healthMeterEl);
var manaMeterEl = new UiMeter { Width = 100, Height = 10, Visible = true };
dict[SelectedObjectController.ManaMeterId] = manaMeterEl;
root.AddChild(manaMeterEl);
var stackEntry = new UiField { Width = 50, Height = 14, Visible = true };
dict[SelectedObjectController.StackSizeEntryId] = stackEntry;
root.AddChild(stackEntry);
@ -78,13 +82,17 @@ public class SelectedObjectControllerTests
public readonly SelectionState Selection = new();
public readonly StackSplitQuantityState SplitQuantity = new();
public Action<uint, float>? HealthHandler;
public Action<uint, float, bool>? ItemManaHandler;
public Action<ClientObject>? ObjectUpdatedHandler;
public readonly List<uint> QueryHealthCalls = new();
public readonly List<uint> QueryItemManaCalls = new();
public readonly Dictionary<uint, bool> HealthTargetMap = new();
public readonly Dictionary<uint, bool> OwnedMap = new();
public readonly Dictionary<uint, string> NameMap = new();
public readonly Dictionary<uint, float> HealthMap = new();
public readonly Dictionary<uint, bool> HasHealthMap = new();
public readonly Dictionary<uint, float> ManaMap = new();
public readonly Dictionary<uint, uint> StackMap = new();
public void FireSelection(uint? g)
@ -95,6 +103,8 @@ public class SelectedObjectControllerTests
Selection.Clear(SelectionChangeSource.System);
}
public void FireHealth(uint g, float pct) => HealthHandler?.Invoke(g, pct);
public void FireItemMana(uint g, float pct, bool valid)
=> ItemManaHandler?.Invoke(g, pct, valid);
public SelectedObjectController Bind(ImportedLayout layout, UiDatFont? datFont = null)
=> SelectedObjectController.Bind(
@ -105,12 +115,20 @@ public class SelectedObjectControllerTests
{
if (HealthHandler == h) HealthHandler = null;
},
subscribeItemManaChanged: h => ItemManaHandler = h,
unsubscribeItemManaChanged: h =>
{
if (ItemManaHandler == h) ItemManaHandler = null;
},
isHealthTarget: g => HealthTargetMap.TryGetValue(g, out var v) && v,
isOwnedByPlayer: g => OwnedMap.TryGetValue(g, out var v) && v,
name: g => NameMap.TryGetValue(g, out var v) ? v : null,
healthPercent: g => HealthMap.TryGetValue(g, out var v) ? v : 1f,
hasHealth: g => HasHealthMap.TryGetValue(g, out var v) && v,
stackSize: g => StackMap.TryGetValue(g, out var v) ? v : 0u,
sendQueryHealth: g => QueryHealthCalls.Add(g),
manaPercent: g => ManaMap.TryGetValue(g, out var v) ? v : 0f,
sendQueryItemMana: g => QueryItemManaCalls.Add(g),
datFont: datFont,
splitQuantity: SplitQuantity,
subscribeObjectUpdated: h => ObjectUpdatedHandler = h,
@ -379,6 +397,7 @@ public class SelectedObjectControllerTests
Assert.False(healthMeterEl.Visible, "meter must be hidden after deselect");
Assert.Equal("", overlayEl.ActiveState);
Assert.Empty(nameEl.Children.OfType<UiText>().Single().LinesProvider());
Assert.Equal(new[] { Guid, 0u }, h.QueryHealthCalls);
}
// ── H5: Re-select a different guid ───────────────────────────────────────
@ -404,7 +423,7 @@ public class SelectedObjectControllerTests
Assert.False(healthMeterEl.Visible, "meter must clear when switching to a non-health target");
Assert.Equal("ObjectSelected", overlayEl.ActiveState);
Assert.Single(h.QueryHealthCalls); // B is not a health target → no extra query
Assert.Equal(new[] { GuidA, 0u }, h.QueryHealthCalls);
var lines = nameEl.Children.OfType<UiText>().Single().LinesProvider();
Assert.Single(lines);
@ -498,6 +517,80 @@ public class SelectedObjectControllerTests
Assert.Equal(0f, healthMeterEl.Fill() ?? 0f);
}
[Fact]
public void OwnedNonStackItem_QueriesMana_AndValidResponseShowsMeter()
{
const uint Guid = 0x50000A01u;
var (layout, _, _, _) = FakeLayout();
var manaMeter = Assert.IsType<UiMeter>(layout.FindElement(SelectedObjectController.ManaMeterId));
var h = new Harness();
h.OwnedMap[Guid] = true;
h.NameMap[Guid] = "Wand";
h.StackMap[Guid] = 1u;
h.ManaMap[Guid] = 0.625f;
h.Bind(layout);
h.FireSelection(Guid);
Assert.Equal(new[] { Guid }, h.QueryItemManaCalls);
Assert.False(manaMeter.Visible);
h.FireItemMana(Guid, 0.625f, valid: true);
Assert.True(manaMeter.Visible);
Assert.Equal(0.625f, manaMeter.Fill());
}
[Fact]
public void InvalidManaResponse_CancelsQueryWithoutShowingMeter()
{
const uint Guid = 0x50000A02u;
var (layout, _, _, _) = FakeLayout();
var manaMeter = Assert.IsType<UiMeter>(layout.FindElement(SelectedObjectController.ManaMeterId));
var h = new Harness();
h.OwnedMap[Guid] = true;
h.StackMap[Guid] = 1u;
h.Bind(layout);
h.FireSelection(Guid);
h.FireItemMana(Guid, 0f, valid: false);
Assert.Equal(new[] { Guid, 0u }, h.QueryItemManaCalls);
Assert.False(manaMeter.Visible);
}
[Fact]
public void ChangingAwayFromVisibleMana_CancelsWithZero()
{
const uint Guid = 0x50000A03u;
var (layout, _, _, _) = FakeLayout();
var h = new Harness();
h.OwnedMap[Guid] = true;
h.StackMap[Guid] = 1u;
h.Bind(layout);
h.FireSelection(Guid);
h.FireItemMana(Guid, 0.5f, valid: true);
h.FireSelection(0x60000001u);
Assert.Equal(new[] { Guid, 0u }, h.QueryItemManaCalls);
}
[Fact]
public void StackedOwnedItem_DoesNotQueryMana()
{
const uint Guid = 0x50000A04u;
var (layout, _, _, _) = FakeLayout();
var h = new Harness();
h.OwnedMap[Guid] = true;
h.StackMap[Guid] = 2u;
h.Bind(layout);
h.FireSelection(Guid);
Assert.Empty(h.QueryItemManaCalls);
}
[Fact]
public void Dispose_UnsubscribesBothHandlers_AndIsIdempotent()
{
@ -512,6 +605,7 @@ public class SelectedObjectControllerTests
h.FireHealth(0xAA09u, 0.5f);
Assert.Null(h.HealthHandler);
Assert.Null(h.ItemManaHandler);
Assert.False(healthMeterEl.Visible);
Assert.Empty(h.QueryHealthCalls);
}