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
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue