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:
parent
e3fc7ac5ba
commit
b7dc91a053
74 changed files with 6669 additions and 238 deletions
306
src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
Normal file
306
src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
Normal 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);
|
||||
}
|
||||
348
src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
Normal file
348
src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue