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:
parent
df460f94c3
commit
fa1da02783
21 changed files with 402 additions and 38 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,6 +449,30 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_QueryItemManaResponse_RoutesValidityToItemManaState()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var itemMana = new ItemManaState();
|
||||
(uint Guid, float Percent, bool Valid)? observed = null;
|
||||
itemMana.ItemManaChanged += (guid, percent, valid) => observed = (guid, percent, valid);
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
|
||||
itemMana: itemMana);
|
||||
|
||||
byte[] payload = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
|
||||
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.375f);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u);
|
||||
var envelope = GameEventEnvelope.TryParse(
|
||||
WrapEnvelope(GameEventType.QueryItemManaResponse, payload));
|
||||
|
||||
dispatcher.Dispatch(envelope!.Value);
|
||||
|
||||
Assert.Equal((0x50000A01u, 0.375f, true), observed);
|
||||
Assert.True(itemMana.HasMana(0x50000A01u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseDone_releasesAppBusyOwnerThroughCallback()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -177,6 +177,24 @@ public sealed class GameEventDispatcherTests
|
|||
Assert.Equal(0.42f, parsed.Value.HealthPercent, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseQueryItemManaResponse_RoundTripIncludingValidity()
|
||||
{
|
||||
// CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0 reads all three fields.
|
||||
byte[] payload = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
|
||||
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.625f);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u);
|
||||
|
||||
var parsed = GameEvents.ParseQueryItemManaResponse(payload);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid);
|
||||
Assert.Equal(0.625f, parsed.Value.ManaPercent);
|
||||
Assert.True(parsed.Value.Valid);
|
||||
Assert.Null(GameEvents.ParseQueryItemManaResponse(payload.AsSpan(0, 8)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWieldObject_acceptsAceEightBytePayload()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,6 +18,22 @@ public sealed class SocialActionsTests
|
|||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryItemMana_HasOpcode0x0263AndGuid()
|
||||
{
|
||||
// CM_Item::Event_QueryItemMana @ 0x006A8610: F7B1, seq, 0x0263, guid.
|
||||
byte[] body = SocialActions.BuildQueryItemMana(seq: 4, itemGuid: 0x50000A01u);
|
||||
|
||||
Assert.Equal(16, body.Length);
|
||||
Assert.Equal(SocialActions.GameActionEnvelope,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body));
|
||||
Assert.Equal(4u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
||||
Assert.Equal(SocialActions.QueryItemManaOpcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0x50000A01u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPingRequest_HasOpcode0x01E9()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -141,6 +141,19 @@ public sealed class WorldSessionCombatTests
|
|||
Assert.Equal(SocialActions.BuildQueryHealth(1, 0x50000007u), captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SendQueryItemMana_UsesRetailQueryItemManaBuilder()
|
||||
{
|
||||
using var session = NewSession();
|
||||
byte[]? captured = null;
|
||||
session.GameActionCapture = body => captured = body;
|
||||
|
||||
session.SendQueryItemMana(0x50000A01u);
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(SocialActions.BuildQueryItemMana(1, 0x50000A01u), captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SendUseWithTarget_UsesRetailBuilder()
|
||||
{
|
||||
|
|
|
|||
33
tests/AcDream.Core.Tests/Items/ItemManaStateTests.cs
Normal file
33
tests/AcDream.Core.Tests/Items/ItemManaStateTests.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public sealed class ItemManaStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidResponseCachesAndRaisesEvent()
|
||||
{
|
||||
var state = new ItemManaState();
|
||||
(uint Guid, float Percent, bool Valid)? observed = null;
|
||||
state.ItemManaChanged += (guid, percent, valid) => observed = (guid, percent, valid);
|
||||
|
||||
state.OnQueryItemManaResponse(0x50000A01u, 0.75f, valid: true);
|
||||
|
||||
Assert.Equal((0x50000A01u, 0.75f, true), observed);
|
||||
Assert.True(state.HasMana(0x50000A01u));
|
||||
Assert.Equal(0.75f, state.GetManaPercent(0x50000A01u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidResponseClearsCachedValueAndRaisesEvent()
|
||||
{
|
||||
var state = new ItemManaState();
|
||||
state.OnQueryItemManaResponse(0x50000A01u, 0.75f, valid: true);
|
||||
|
||||
state.OnQueryItemManaResponse(0x50000A01u, 0f, valid: false);
|
||||
|
||||
Assert.False(state.HasMana(0x50000A01u));
|
||||
Assert.Equal(0f, state.GetManaPercent(0x50000A01u));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue