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

View file

@ -4,6 +4,12 @@ using System.Collections.Generic;
namespace AcDream.Core.Items;
/// <summary>
/// Ordered container snapshot entry from retail ContentProfile:
/// guid plus m_uContainerProperties.
/// </summary>
public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType);
/// <summary>
/// The client's table of every server object (retail <c>weenie_object_table</c> /
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
@ -69,6 +75,7 @@ public sealed class ClientObjectTable
/// the typed mirror <see cref="UpdateIntProperty"/> maintains on
/// <see cref="ClientObject.Effects"/>.</summary>
public const uint UiEffectsPropertyId = 18u;
public const uint CurrentWieldedLocationPropertyId = 10u;
public int ObjectCount => _objects.Count;
public int ContainerCount => _containers.Count;
@ -124,7 +131,7 @@ public sealed class ClientObjectTable
/// so RollbackMove restores full pre-move state through this method alone.
/// </summary>
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
EquipMask newEquipLocation = EquipMask.None)
EquipMask newEquipLocation = EquipMask.None, uint? containerTypeHint = null)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
@ -132,6 +139,7 @@ public sealed class ClientObjectTable
item.ContainerId = newContainerId;
item.ContainerSlot = newSlot;
item.CurrentlyEquippedLocation = newEquipLocation;
if (containerTypeHint is { } hint) item.ContainerTypeHint = hint;
Reindex(item, oldContainer);
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
return true;
@ -306,6 +314,23 @@ public sealed class ClientObjectTable
if (!_objects.TryGetValue(itemId, out var item)) return false;
item.Properties.Ints[propertyId] = value;
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
if (propertyId == CurrentWieldedLocationPropertyId)
item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
ObjectUpdated?.Invoke(item);
return true;
}
/// <summary>
/// Apply a single PropertyInt64 update to an object's bundle and fire
/// ObjectUpdated so bound widgets refresh. Int64 counterpart of
/// <see cref="UpdateIntProperty"/> (used by the character sheet's
/// optimistic unassigned-XP debit; also the hook for future
/// PrivateUpdatePropertyInt64 parsing). False if the object is unknown.
/// </summary>
public bool UpdateInt64Property(uint itemId, uint propertyId, long value)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
item.Properties.Int64s[propertyId] = value;
ObjectUpdated?.Invoke(item);
return true;
}
@ -359,6 +384,8 @@ public sealed class ClientObjectTable
if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl;
if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl;
if (d.Priority is { } pr) obj.Priority = pr;
if (d.Useability is { } use) obj.Useability = use;
if (d.TargetType is { } targetType) obj.TargetType = targetType;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
if (d.Structure is { } st) obj.Structure = st;
@ -377,7 +404,7 @@ public sealed class ClientObjectTable
/// icon/name/type/effects — that data comes from CreateObject.
/// </summary>
public ClientObject RecordMembership(uint guid, uint containerId = 0,
EquipMask equip = EquipMask.None)
EquipMask equip = EquipMask.None, uint? containerTypeHint = null)
{
bool existed = _objects.TryGetValue(guid, out var obj);
if (!existed || obj is null) // keep: satisfies nullable flow analysis
@ -386,8 +413,16 @@ public sealed class ClientObjectTable
_objects[guid] = obj;
}
uint oldContainer = obj.ContainerId;
if (containerId != 0) obj.ContainerId = containerId;
if (equip != EquipMask.None) obj.CurrentlyEquippedLocation = equip;
if (containerId != 0)
{
obj.ContainerId = containerId;
obj.CurrentlyEquippedLocation = EquipMask.None;
}
else
{
obj.CurrentlyEquippedLocation = equip;
}
if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint;
Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
return obj;
@ -404,12 +439,21 @@ public sealed class ClientObjectTable
if (!_containerIndex.TryGetValue(obj.ContainerId, out var list))
_containerIndex[obj.ContainerId] = list = new List<uint>();
if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId);
list.Sort((a, b) => SlotOf(a).CompareTo(SlotOf(b)));
var priorOrder = new Dictionary<uint, int>(list.Count);
for (int i = 0; i < list.Count; i++)
priorOrder[list[i]] = i;
list.Sort((a, b) =>
{
int c = SlotOf(a).CompareTo(SlotOf(b));
return c != 0 ? c : priorOrder[a].CompareTo(priorOrder[b]);
});
}
}
private int SlotOf(uint guid) =>
_objects.TryGetValue(guid, out var o) ? o.ContainerSlot : int.MaxValue;
_objects.TryGetValue(guid, out var o) && o.ContainerSlot >= 0
? o.ContainerSlot
: int.MaxValue;
/// <summary>
/// Ordered item guids in a container (retail object_inventory_table), by ContainerSlot.
@ -433,7 +477,26 @@ public sealed class ClientObjectTable
ArgumentNullException.ThrowIfNull(guids);
if (containerId == 0) return;
var keep = new HashSet<uint>(guids);
var entries = new ContainerContentEntry[guids.Count];
for (int i = 0; i < guids.Count; i++)
entries[i] = new ContainerContentEntry(guids[i], 0u);
ReplaceContents(containerId, entries);
}
/// <summary>
/// Replace a container's entire membership with <paramref name="entries"/> in retail order.
/// PlayerDescription and ViewContents both carry ContentProfile entries; the order is the
/// dense list order retail feeds to ACCObjectMaint::ViewObjectContents, while ContainerType
/// chooses the loose-item list versus the side-pack/container list.
/// </summary>
public void ReplaceContents(uint containerId, IReadOnlyList<ContainerContentEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
if (containerId == 0) return;
var keep = new HashSet<uint>();
foreach (var entry in entries)
keep.Add(entry.Guid);
// Detach prior members no longer present (they left the container server-side). GetContents
// returns a snapshot, so mutating the index inside the loop is safe.
@ -442,23 +505,26 @@ public sealed class ClientObjectTable
if (keep.Contains(old)) continue;
if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue;
o.ContainerId = 0;
o.CurrentlyEquippedLocation = EquipMask.None;
Reindex(o, containerId);
ObjectMoved?.Invoke(o, containerId, 0);
}
// Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition.
for (int i = 0; i < guids.Count; i++)
for (int i = 0; i < entries.Count; i++)
{
uint g = guids[i];
bool existed = _objects.TryGetValue(g, out var obj);
var entry = entries[i];
bool existed = _objects.TryGetValue(entry.Guid, out var obj);
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = g };
_objects[g] = obj;
obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj;
}
uint oldContainer = obj.ContainerId;
obj.ContainerId = containerId;
obj.ContainerSlot = i;
obj.CurrentlyEquippedLocation = EquipMask.None;
obj.ContainerTypeHint = entry.ContainerType;
Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj);
else ObjectMoved?.Invoke(obj, oldContainer, containerId);

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.Core.Player;
@ -87,10 +88,27 @@ public sealed class LocalPlayerState
/// on primary-attribute state held elsewhere on the cache.</summary>
public readonly record struct VitalSnapshot(uint Ranks, uint Start, uint Xp, uint Current);
/// <summary>Per-skill snapshot from PlayerDescription's CreatureSkill table.</summary>
public readonly record struct SkillSnapshot(
uint SkillId,
uint Ranks,
uint Status,
uint Xp,
uint Init,
uint Resistance,
double LastUsed,
uint FormulaBonus)
{
public uint BaseLevel => FormulaBonus + Init + Ranks;
public uint CurrentLevel => BaseLevel;
}
private VitalSnapshot? _health;
private VitalSnapshot? _stamina;
private VitalSnapshot? _mana;
private readonly Dictionary<AttributeKind, AttributeSnapshot> _attrs = new();
private readonly Dictionary<uint, SkillSnapshot> _skills = new();
private PropertyBundle _properties = new();
private readonly Spellbook? _spellbook;
/// <summary>
@ -112,6 +130,9 @@ public sealed class LocalPlayerState
/// only at PlayerDescription / future <c>PrivateUpdateAttribute</c>).</summary>
public event System.Action<AttributeKind>? AttributeChanged;
/// <summary>Fires after player properties or skills from PlayerDescription change.</summary>
public event System.Action? CharacterChanged;
/// <summary>
/// Map a vital-id (across both ID systems) to a <see cref="VitalKind"/>.
/// <list type="bullet">
@ -158,6 +179,16 @@ public sealed class LocalPlayerState
public AttributeSnapshot? GetAttribute(AttributeKind kind) =>
_attrs.TryGetValue(kind, out var a) ? a : null;
/// <summary>Snapshot of the local player's current property bundle.</summary>
public PropertyBundle Properties => _properties;
/// <summary>All known skill snapshots, keyed by SkillId.</summary>
public IReadOnlyDictionary<uint, SkillSnapshot> Skills => _skills;
/// <summary>Snapshot for one skill, or <c>null</c> if it has not arrived yet.</summary>
public SkillSnapshot? GetSkill(uint skillId) =>
_skills.TryGetValue(skillId, out var s) ? s : null;
/// <summary>
/// Compute the buffed max for a vital, using the full retail formula:
/// <c>(vital.(ranks+start) + attribute_contribution) × multiplier_buff + additive_buff</c>
@ -273,6 +304,134 @@ public sealed class LocalPlayerState
AttributeChanged?.Invoke(kind);
}
/// <summary>Replace the local player's top-level property snapshot from PlayerDescription.</summary>
public void OnProperties(PropertyBundle properties)
{
_properties = properties.Clone();
CharacterChanged?.Invoke();
}
/// <summary>Apply or replace one PlayerDescription skill entry.</summary>
public void OnSkillUpdate(
uint skillId,
uint ranks,
uint status,
uint xp,
uint init,
uint resistance,
double lastUsed,
uint formulaBonus)
{
_skills[skillId] = new SkillSnapshot(
skillId, ranks, status, xp, init, resistance, lastUsed, formulaBonus);
CharacterChanged?.Invoke();
}
/// <summary>
/// Optimistically apply a successful local attribute-raise action.
/// The next server snapshot remains authoritative; this keeps UI state current
/// during the round trip after sending the retail raise action.
/// </summary>
public bool ApplyAttributeRaise(uint atType, uint amount, ulong xpSpent)
{
if (AttributeIdToKind(atType) is not AttributeKind kind) return false;
if (!_attrs.TryGetValue(kind, out var prev)) return false;
_attrs[kind] = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
AttributeChanged?.Invoke(kind);
return true;
}
/// <summary>Optimistically apply a successful local max-vital raise action.</summary>
public bool ApplyVitalRaise(uint vitalId, uint amount, ulong xpSpent)
{
if (VitalIdToKind(vitalId) is not VitalKind kind) return false;
VitalSnapshot? existing = Get(kind);
if (existing is not VitalSnapshot prev) return false;
var snap = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
switch (kind)
{
case VitalKind.Health: _health = snap; break;
case VitalKind.Stamina: _stamina = snap; break;
case VitalKind.Mana: _mana = snap; break;
}
Changed?.Invoke(kind);
return true;
}
/// <summary>Optimistically promote an untrained skill after a successful TrainSkill action.</summary>
public bool ApplySkillTraining(uint skillId)
{
if (!_skills.TryGetValue(skillId, out var prev)) return false;
if (prev.Status >= 2u) return false;
_skills[skillId] = prev with { Status = 2u };
CharacterChanged?.Invoke();
return true;
}
/// <summary>Optimistically apply a successful local skill-raise action.</summary>
public bool ApplySkillRaise(uint skillId, uint amount, ulong xpSpent)
{
if (!_skills.TryGetValue(skillId, out var prev)) return false;
if (prev.Status < 2u) return false;
_skills[skillId] = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
CharacterChanged?.Invoke();
return true;
}
/// <summary>
/// Optimistically debit an int property in the player's property bundle
/// (clamped at 0), firing <see cref="CharacterChanged"/> so bound UI
/// refreshes. False if the property is absent — callers walk their
/// fallback id chain. The next server snapshot remains authoritative.
/// All local-player property writes go through eventful APIs like this
/// one; writing <see cref="Properties"/> dictionaries directly skips the
/// change event and is a single-owner-state violation.
/// </summary>
public bool DebitIntProperty(uint propertyId, int amount)
{
if (!_properties.Ints.TryGetValue(propertyId, out int current))
return false;
_properties.Ints[propertyId] = current > amount ? current - amount : 0;
CharacterChanged?.Invoke();
return true;
}
/// <summary>
/// Optimistically debit an int64 property (clamped at 0), firing
/// <see cref="CharacterChanged"/>. False if the property is absent or
/// already ≤ 0. The next server snapshot remains authoritative.
/// </summary>
public bool DebitInt64Property(uint propertyId, long amount)
{
if (!_properties.Int64s.TryGetValue(propertyId, out long current) || current <= 0)
return false;
_properties.Int64s[propertyId] = current > amount ? current - amount : 0L;
CharacterChanged?.Invoke();
return true;
}
private static uint SaturatingAdd(uint value, uint delta)
=> uint.MaxValue - value < delta ? uint.MaxValue : value + delta;
private static uint SaturatingAdd(uint value, ulong delta)
=> delta > uint.MaxValue - value ? uint.MaxValue : value + (uint)delta;
// ── Retail attribute contribution ──────────────────────────────────────
//
// Source: ACE Source/ACE.Server/Entity/AttributeFormula.cs +