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

@ -117,9 +117,23 @@ public sealed class PropertyBundle
public Dictionary<uint, uint> InstanceIds { get; } = new();
public int GetInt (uint k, int def = 0) => Ints.TryGetValue(k, out var v) ? v : def;
public long GetInt64 (uint k, long def = 0) => Int64s.TryGetValue(k, out var v) ? v : def;
public bool GetBool (uint k, bool def = false) => Bools.TryGetValue(k, out var v) ? v : def;
public double GetFloat (uint k, double def = 0) => Floats.TryGetValue(k, out var v) ? v : def;
public string GetString(uint k, string def = "") => Strings.TryGetValue(k, out var v) ? v : def;
public PropertyBundle Clone()
{
var copy = new PropertyBundle();
foreach (var kv in Ints) copy.Ints[kv.Key] = kv.Value;
foreach (var kv in Int64s) copy.Int64s[kv.Key] = kv.Value;
foreach (var kv in Bools) copy.Bools[kv.Key] = kv.Value;
foreach (var kv in Floats) copy.Floats[kv.Key] = kv.Value;
foreach (var kv in Strings) copy.Strings[kv.Key] = kv.Value;
foreach (var kv in DataIds) copy.DataIds[kv.Key] = kv.Value;
foreach (var kv in InstanceIds) copy.InstanceIds[kv.Key] = kv.Value;
return copy;
}
}
/// <summary>
@ -150,12 +164,20 @@ public sealed class ClientObject
public int Value { get; set; } // pyreals
public uint ContainerId { get; set; } // parent container ObjectId, or 0
public int ContainerSlot { get; set; } = -1;
/// <summary>
/// Retail <c>ContentProfile.m_uContainerProperties</c> hint from
/// PlayerDescription/ViewContents: 0 = loose item list, nonzero = container list.
/// Kept separate from <see cref="Type"/> because CreateObject owns real item data.
/// </summary>
public uint ContainerTypeHint { get; set; }
public bool Attuned { get; set; }
public bool Bonded { get; set; }
public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded
public int ItemsCapacity { get; set; } // main-pack slots (containers)
public int ContainersCapacity{ get; set; } // side-pack slots (containers)
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
public int Structure { get; set; } // charges/uses remaining
public int MaxStructure { get; set; }
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
@ -193,7 +215,52 @@ public readonly record struct WeenieData(
int? ContainersCapacity,
int? Structure,
int? MaxStructure,
float? Workmanship);
float? Workmanship,
uint? Useability = null,
uint? TargetType = null);
/// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
/// Low 16 bits describe where the source may be used from; high 16 bits
/// describe where the acquired target may be.
/// </summary>
public static class ItemUseability
{
public const uint Undef = 0x0u;
public const uint No = 0x1u;
public const uint Self = 0x2u;
public const uint Wielded = 0x4u;
public const uint Contained = 0x8u;
public const uint Viewed = 0x10u;
public const uint Remote = 0x20u;
public const uint NeverWalk = 0x40u;
public const uint ObjSelf = 0x80u;
public const uint SourceMask = 0x0000FFFFu;
public const uint TargetMask = 0xFFFF0000u;
public static bool IsTargeted(uint useability)
=> (useability & TargetMask) != 0;
public static bool AllowsSelfTarget(uint useability)
=> ((useability >> 16) & Self) != 0;
public static bool AllowsObjectSelfTarget(uint useability)
=> ((useability >> 16) & ObjSelf) != 0;
public static uint SourceFlags(uint useability)
=> useability & SourceMask;
public static uint TargetFlags(uint useability)
=> (useability & TargetMask) >> 16;
public static bool IsDirectUseable(uint useability)
{
uint source = SourceFlags(useability);
return !IsTargeted(useability)
&& (source & ~(No | NeverWalk)) != Undef;
}
}
/// <summary>
/// Container = inventory pack. Hierarchy is strictly 2-deep: character