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

@ -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);