feat(ui): D.2b item interaction + retail cursors + live character sheet

Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02
UI architecture review mandated before commit:

- ItemInteractionController: single owner of double-click use/equip/
  container-open, targeted-use mode (health kits), drag-out drop;
  toolbar shortcut drags don't drop the real item. ItemEquipRules for
  multi-slot (coat) coverage via equip masks.
- Cursor phase: CursorFeedbackController (semantic priority chain:
  drag > resize > window-move > target-mode > text) + RetailCursorCatalog
  (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState
  0x00564630) resolved through the portal EnumIDMap chain by
  RetailCursorResolver; RetailCursorManager applies dat cursor art to the
  OS cursor. Register row AP-72 covers the OS standard-cursor fallback.
- Character window goes live: CharacterSheetProvider owns sheet assembly,
  XP-curve/raise-cost math and the raise flow — extracted out of
  GameWindow per Code Structure Rule 1 instead of committing the ~430-line
  feature body there. Optimistic XP/credit debits go through eventful
  store APIs (new ClientObjectTable.UpdateInt64Property +
  LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw
  property-dictionary writes; register row AP-73 covers the still-missing
  raise ledger (#163).
- RetailWindowFrame: the shared nine-slice window mount recipe; the
  character window uses it, remaining windows migrate via #164.
- Status-bar buttons toggle inventory/character windows; retail row-major
  backpack ordering; WorldSession.SendUseWithTarget + raise/train sends.

GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the
sheet/raise logic is unit-tested in CharacterSheetProviderTests instead
of trapped in the god object. Build green; full suite 3,286 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 09:18:43 +02:00
parent e3fc7ac5ba
commit b7dc91a053
74 changed files with 6669 additions and 238 deletions

View file

@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using AcDream.Core.Items;
namespace AcDream.App.UI.Testing;
/// <summary>
/// Snapshot row for the live retained-mode retail UI tree. Coordinates are
/// absolute root/screen pixels.
/// </summary>
public sealed record RetailUiProbeElement(
int Index,
int Depth,
string Path,
string TypeName,
string? Name,
uint EventId,
uint DatElementId,
float X,
float Y,
float Width,
float Height,
bool Visible,
bool Enabled,
uint ItemId,
int SlotIndex,
ItemDragSource? SourceKind)
{
public int CenterX => (int)MathF.Round(X + Width * 0.5f);
public int CenterY => (int)MathF.Round(Y + Height * 0.5f);
}
public sealed record RetailUiProbeAssertion(bool Success, string Message);
/// <summary>
/// Test/diagnostic harness for the retail-style UI. It drives <see cref="UiRoot"/>
/// mouse events, never panel controllers directly, so automated checks exercise
/// the same click, double-click, and drag-drop path as a player.
/// </summary>
public sealed class RetailUiAutomationProbe
{
private readonly UiRoot _root;
private readonly ClientObjectTable _objects;
private readonly Action<string>? _log;
private long _nowMs = 1;
public RetailUiAutomationProbe(
UiRoot root,
ClientObjectTable objects,
Action<string>? log = null)
{
_root = root ?? throw new ArgumentNullException(nameof(root));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_log = log;
}
public IReadOnlyList<RetailUiProbeElement> Snapshot(bool visibleOnly = true)
{
var rows = new List<RetailUiProbeElement>();
Walk(_root, "root", depth: 0, ancestorsVisible: true, visibleOnly, rows);
return rows;
}
public string DumpText(bool visibleOnly = true)
{
var sb = new StringBuilder();
foreach (var e in Snapshot(visibleOnly))
{
sb.Append('[').Append(e.Index.ToString(CultureInfo.InvariantCulture)).Append("] ");
sb.Append(new string(' ', Math.Max(0, e.Depth) * 2));
sb.Append(e.TypeName);
if (e.DatElementId != 0) sb.Append(" dat=0x").Append(e.DatElementId.ToString("X8", CultureInfo.InvariantCulture));
if (e.EventId != 0) sb.Append(" event=0x").Append(e.EventId.ToString("X8", CultureInfo.InvariantCulture));
if (!string.IsNullOrEmpty(e.Name)) sb.Append(" name=").Append(e.Name);
sb.Append(" rect=(").Append(F(e.X)).Append(',').Append(F(e.Y)).Append(',')
.Append(F(e.Width)).Append(',').Append(F(e.Height)).Append(')');
if (!e.Visible) sb.Append(" hidden");
if (!e.Enabled) sb.Append(" disabled");
if (e.ItemId != 0)
{
sb.Append(" item=0x").Append(e.ItemId.ToString("X8", CultureInfo.InvariantCulture));
sb.Append(" slot=").Append(e.SlotIndex.ToString(CultureInfo.InvariantCulture));
if (e.SourceKind is { } source) sb.Append(" source=").Append(source);
}
sb.Append(" path=").Append(e.Path);
sb.AppendLine();
}
return sb.ToString();
}
public RetailUiProbeElement? FindByDatElementId(uint datElementId, bool visibleOnly = true)
{
foreach (var e in Snapshot(visibleOnly))
if (e.DatElementId == datElementId && HasArea(e))
return e;
return null;
}
public IReadOnlyList<RetailUiProbeElement> FindAllByDatElementId(uint datElementId, bool visibleOnly = true)
{
var matches = new List<RetailUiProbeElement>();
foreach (var e in Snapshot(visibleOnly))
if (e.DatElementId == datElementId)
matches.Add(e);
return matches;
}
public RetailUiProbeElement? FindByItemId(
uint itemGuid,
ItemDragSource? sourceKind = null,
bool visibleOnly = true)
{
if (itemGuid == 0) return null;
foreach (var e in Snapshot(visibleOnly))
{
if (e.ItemId != itemGuid) continue;
if (sourceKind is { } source && e.SourceKind != source) continue;
if (!HasArea(e)) continue;
return e;
}
return null;
}
public bool ClickElement(uint datElementId)
{
var target = FindByDatElementId(datElementId);
if (target is null) return Fail($"element 0x{datElementId:X8} not found");
ClickAt(target.CenterX, target.CenterY);
return true;
}
public bool ClickItem(uint itemGuid, ItemDragSource? sourceKind = null)
{
var target = FindByItemId(itemGuid, sourceKind);
if (target is null) return Fail($"item 0x{itemGuid:X8} not found");
ClickAt(target.CenterX, target.CenterY);
return true;
}
public bool DoubleClickItem(uint itemGuid, ItemDragSource? sourceKind = null)
{
var target = FindByItemId(itemGuid, sourceKind);
if (target is null) return Fail($"item 0x{itemGuid:X8} not found");
ClickAt(target.CenterX, target.CenterY);
Advance(80);
ClickAt(target.CenterX, target.CenterY);
return true;
}
public bool DragItemToElement(uint itemGuid, uint datElementId, ItemDragSource? sourceKind = null)
{
var source = FindByItemId(itemGuid, sourceKind);
if (source is null) return Fail($"drag source item 0x{itemGuid:X8} not found");
var target = FindByDatElementId(datElementId);
if (target is null) return Fail($"drop target element 0x{datElementId:X8} not found");
DragAt(source.CenterX, source.CenterY, target.CenterX, target.CenterY);
return true;
}
public bool DragItemToItem(uint sourceGuid, uint targetGuid, ItemDragSource? sourceKind = null)
{
var source = FindByItemId(sourceGuid, sourceKind);
if (source is null) return Fail($"drag source item 0x{sourceGuid:X8} not found");
var target = FindByItemId(targetGuid);
if (target is null) return Fail($"drop target item 0x{targetGuid:X8} not found");
DragAt(source.CenterX, source.CenterY, target.CenterX, target.CenterY);
return true;
}
public bool DragItemOutside(uint itemGuid, int x, int y, ItemDragSource? sourceKind = null)
{
var source = FindByItemId(itemGuid, sourceKind);
if (source is null) return Fail($"drag source item 0x{itemGuid:X8} not found");
DragAt(source.CenterX, source.CenterY, x, y);
return true;
}
public RetailUiProbeAssertion AssertItem(
uint itemGuid,
EquipMask? equippedLocation = null,
uint? containerId = null,
int? slot = null)
{
var item = _objects.Get(itemGuid);
if (item is null)
return Result(false, $"item 0x{itemGuid:X8} missing from object table");
if (equippedLocation is { } equip && item.CurrentlyEquippedLocation != equip)
return Result(false,
$"item 0x{itemGuid:X8} equip expected 0x{((uint)equip):X8}, got 0x{((uint)item.CurrentlyEquippedLocation):X8}");
if (containerId is { } container && item.ContainerId != container)
return Result(false,
$"item 0x{itemGuid:X8} container expected 0x{container:X8}, got 0x{item.ContainerId:X8}");
if (slot is { } slotValue && item.ContainerSlot != slotValue)
return Result(false,
$"item 0x{itemGuid:X8} slot expected {slotValue}, got {item.ContainerSlot}");
return Result(true, $"item 0x{itemGuid:X8} matched object-table assertion");
}
private void Walk(
UiElement element,
string path,
int depth,
bool ancestorsVisible,
bool visibleOnly,
List<RetailUiProbeElement> rows)
{
bool effectiveVisible = ancestorsVisible && element.Visible;
if (visibleOnly && !effectiveVisible) return;
if (element is UiItemList list)
list.LayoutCells();
var pos = element.ScreenPosition;
uint itemId = 0;
int slotIndex = -1;
ItemDragSource? sourceKind = null;
if (element is UiItemSlot slot)
{
itemId = slot.ItemId;
slotIndex = slot.SlotIndex;
sourceKind = slot.SourceKind;
}
rows.Add(new RetailUiProbeElement(
rows.Count,
depth,
path,
element.GetType().Name,
element.Name,
element.EventId,
element.DatElementId,
pos.X,
pos.Y,
element.Width,
element.Height,
effectiveVisible,
element.Enabled,
itemId,
slotIndex,
sourceKind));
var children = element.Children;
for (int i = 0; i < children.Count; i++)
{
var child = children[i];
var childPath = $"{path}/{child.GetType().Name}[{i}]";
if (child.DatElementId != 0)
childPath += $"#0x{child.DatElementId:X8}";
Walk(child, childPath, depth + 1, effectiveVisible, visibleOnly, rows);
}
}
private void ClickAt(int x, int y)
{
Advance(16);
_root.OnMouseMove(x, y);
_root.OnMouseDown(UiMouseButton.Left, x, y);
Advance(50);
_root.OnMouseUp(UiMouseButton.Left, x, y);
Advance(16);
}
private void DragAt(int startX, int startY, int endX, int endY)
{
Advance(16);
_root.OnMouseMove(startX, startY);
_root.OnMouseDown(UiMouseButton.Left, startX, startY);
Advance(16);
_root.OnMouseMove(startX + 5, startY + 5);
Advance(16);
_root.OnMouseMove(endX, endY);
Advance(16);
_root.OnMouseUp(UiMouseButton.Left, endX, endY);
Advance(16);
}
private void Advance(int ms)
{
_nowMs += ms;
_root.Tick(ms / 1000.0, _nowMs);
}
private static bool HasArea(RetailUiProbeElement element)
=> element.Width > 0f && element.Height > 0f;
private bool Fail(string message)
{
_log?.Invoke(message);
return false;
}
private RetailUiProbeAssertion Result(bool success, string message)
{
_log?.Invoke(message);
return new RetailUiProbeAssertion(success, message);
}
private static string F(float value)
=> value.ToString("0.##", CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,348 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using AcDream.Core.Items;
namespace AcDream.App.UI.Testing;
/// <summary>
/// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>.
/// It is intended for local diagnostic launches, not for gameplay. Commands
/// execute on render ticks and use the same <see cref="UiRoot"/> input path as
/// physical mouse input.
/// </summary>
public sealed class RetailUiAutomationScriptRunner
{
private readonly RetailUiAutomationProbe _probe;
private readonly Action<string> _log;
private readonly List<ScriptCommand> _commands = new();
private readonly bool _dumpOnStart;
private readonly string? _loadError;
private int _index;
private int _activeIndex = -1;
private long _elapsedMs;
private long _commandStartMs;
private bool _started;
private bool _completed;
public RetailUiAutomationScriptRunner(
RetailUiAutomationProbe probe,
string? scriptPath,
bool dumpOnStart,
Action<string>? log = null)
{
_probe = probe ?? throw new ArgumentNullException(nameof(probe));
_log = log ?? (_ => { });
_dumpOnStart = dumpOnStart;
if (!string.IsNullOrWhiteSpace(scriptPath))
{
try
{
int lineNumber = 0;
foreach (var raw in File.ReadAllLines(scriptPath))
{
lineNumber++;
var line = StripComment(raw).Trim();
if (line.Length == 0) continue;
_commands.Add(new ScriptCommand(lineNumber, line, Split(line)));
}
}
catch (Exception ex)
{
_loadError = $"failed to load UI probe script '{scriptPath}': {ex.Message}";
}
}
}
public bool Completed => _completed;
public void Tick(double deltaSeconds)
{
if (_completed) return;
long addMs = Math.Max(1, (long)Math.Round(deltaSeconds * 1000.0));
_elapsedMs += addMs;
if (!_started)
{
_started = true;
if (_loadError is not null)
{
_log(_loadError);
_completed = true;
return;
}
if (_dumpOnStart)
_log(_probe.DumpText());
if (_commands.Count == 0)
{
_completed = true;
return;
}
_log($"running {_commands.Count} UI probe command(s)");
}
int guard = 0;
while (!_completed && _index < _commands.Count && guard++ < 8)
{
if (_activeIndex != _index)
{
_activeIndex = _index;
_commandStartMs = _elapsedMs;
}
var command = _commands[_index];
bool finished = Execute(command);
if (!finished) break;
_index++;
_activeIndex = -1;
}
if (_index >= _commands.Count && !_completed)
{
_completed = true;
_log("UI probe script complete");
}
}
private bool Execute(ScriptCommand command)
{
var p = command.Parts;
if (p.Length == 0) return true;
string verb = p[0].ToLowerInvariant();
return verb switch
{
"dump" => DoDump(),
"click" => DoClick(command),
"doubleclick" => DoDoubleClick(command),
"drag" => DoDrag(command),
"wait" => DoWait(command),
"sleep" => DoSleep(command),
"assert" => DoAssert(command),
_ => Stop(command, $"unknown command '{p[0]}'"),
};
}
private bool DoDump()
{
_log(_probe.DumpText());
return true;
}
private bool DoClick(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 3) return Stop(command, "usage: click element <datId> | click item <guid> [source]");
string target = p[1].ToLowerInvariant();
if (target == "element")
{
if (!TryParseUInt(p[2], out uint datId)) return Stop(command, $"bad element id '{p[2]}'");
return _probe.ClickElement(datId) || Stop(command, "click element failed");
}
if (target == "item")
{
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
return _probe.ClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "click item failed");
}
return Stop(command, "usage: click element <datId> | click item <guid> [source]");
}
private bool DoDoubleClick(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 3 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
return Stop(command, "usage: doubleclick item <guid> [source]");
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
return _probe.DoubleClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "doubleclick item failed");
}
private bool DoDrag(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 5 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
return Stop(command, "usage: drag item <guid> element <datId> | drag item <guid> item <guid> | drag item <guid> outside <x> <y>");
if (!TryParseUInt(p[2], out uint sourceGuid)) return Stop(command, $"bad item guid '{p[2]}'");
string target = p[3].ToLowerInvariant();
if (target == "element")
{
if (!TryParseUInt(p[4], out uint datId)) return Stop(command, $"bad element id '{p[4]}'");
return _probe.DragItemToElement(sourceGuid, datId, ParseSource(p, 5))
|| Stop(command, "drag item to element failed");
}
if (target == "item")
{
if (!TryParseUInt(p[4], out uint targetGuid)) return Stop(command, $"bad target item guid '{p[4]}'");
return _probe.DragItemToItem(sourceGuid, targetGuid, ParseSource(p, 5))
|| Stop(command, "drag item to item failed");
}
if (target == "outside")
{
if (p.Length < 6) return Stop(command, "usage: drag item <guid> outside <x> <y> [source]");
if (!TryParseInt(p[4], out int x)) return Stop(command, $"bad x coordinate '{p[4]}'");
if (!TryParseInt(p[5], out int y)) return Stop(command, $"bad y coordinate '{p[5]}'");
return _probe.DragItemOutside(sourceGuid, x, y, ParseSource(p, 6))
|| Stop(command, "drag item outside failed");
}
return Stop(command, "usage: drag item <guid> element/item/outside ...");
}
private bool DoWait(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 2) return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>");
string target = p[1].ToLowerInvariant();
if (target == "item")
{
if (p.Length < 3 || !TryParseUInt(p[2], out uint itemGuid)) return Stop(command, "usage: wait item <guid> [source] [timeoutMs]");
if (_probe.FindByItemId(itemGuid, ParseSource(p, 3)) is not null) return true;
return WaitOrTimeout(command, TimeoutMs(p, 3, 10000), $"item 0x{itemGuid:X8}");
}
if (target == "element")
{
if (p.Length < 3 || !TryParseUInt(p[2], out uint datId)) return Stop(command, "usage: wait element <datId> [timeoutMs]");
if (_probe.FindByDatElementId(datId) is not null) return true;
return WaitOrTimeout(command, TimeoutMs(p, 3, 10000), $"element 0x{datId:X8}");
}
if (target == "ms")
return DoSleep(command);
return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>");
}
private bool DoSleep(ScriptCommand command)
{
var p = command.Parts;
string value = p.Length >= 3 && string.Equals(p[0], "wait", StringComparison.OrdinalIgnoreCase) ? p[2]
: p.Length >= 2 ? p[1] : "";
if (!TryParseInt(value, out int ms) || ms < 0)
return Stop(command, "usage: sleep <milliseconds> | wait ms <milliseconds>");
return _elapsedMs - _commandStartMs >= ms;
}
private bool DoAssert(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 5 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
return Stop(command, "usage: assert item <guid> equip|container|slot <value>");
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
string kind = p[3].ToLowerInvariant();
RetailUiProbeAssertion result;
if (kind == "equip")
{
if (!TryParseUInt(p[4], out uint mask)) return Stop(command, $"bad equip mask '{p[4]}'");
result = _probe.AssertItem(itemGuid, equippedLocation: (EquipMask)mask);
}
else if (kind == "container")
{
if (!TryParseUInt(p[4], out uint containerId)) return Stop(command, $"bad container id '{p[4]}'");
result = _probe.AssertItem(itemGuid, containerId: containerId);
}
else if (kind == "slot")
{
if (!TryParseInt(p[4], out int slot)) return Stop(command, $"bad slot '{p[4]}'");
result = _probe.AssertItem(itemGuid, slot: slot);
}
else
{
return Stop(command, "usage: assert item <guid> equip|container|slot <value>");
}
return result.Success || Stop(command, result.Message);
}
private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
{
if (_elapsedMs - _commandStartMs <= timeoutMs) return false;
return Stop(command, $"timed out waiting for {label}");
}
private bool Stop(ScriptCommand command, string message)
{
_log($"line {command.LineNumber}: {message}; command: {command.Text}");
_completed = true;
return false;
}
private static string StripComment(string line)
{
int hash = line.IndexOf('#');
int slashes = line.IndexOf("//", StringComparison.Ordinal);
int cut = -1;
if (hash >= 0) cut = hash;
if (slashes >= 0) cut = cut >= 0 ? Math.Min(cut, slashes) : slashes;
return cut >= 0 ? line[..cut] : line;
}
private static string[] Split(string line)
=> line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static ItemDragSource? ParseSource(string[] parts, int start)
{
for (int i = start; i < parts.Length; i++)
if (TryParseSource(parts[i], out var source))
return source;
return null;
}
private static bool TryParseSource(string value, out ItemDragSource source)
{
switch (value.ToLowerInvariant())
{
case "inventory":
case "pack":
case "backpack":
source = ItemDragSource.Inventory;
return true;
case "shortcut":
case "shortcutbar":
case "toolbar":
source = ItemDragSource.ShortcutBar;
return true;
case "equipment":
case "equip":
case "paperdoll":
source = ItemDragSource.Equipment;
return true;
case "ground":
source = ItemDragSource.Ground;
return true;
default:
source = default;
return false;
}
}
private static int TimeoutMs(string[] parts, int start, int defaultMs)
{
for (int i = start; i < parts.Length; i++)
if (TryParseInt(parts[i], out int ms) && ms >= 0)
return ms;
return defaultMs;
}
private static bool TryParseUInt(string value, out uint parsed)
{
if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return uint.TryParse(value[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed);
return uint.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
}
private static bool TryParseInt(string value, out int parsed)
=> int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
private readonly record struct ScriptCommand(int LineNumber, string Text, string[] Parts);
}