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