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
478
src/AcDream.App/UI/Layout/CharacterSheetProvider.cs
Normal file
478
src/AcDream.App/UI/Layout/CharacterSheetProvider.cs
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Player;
|
||||
using DatReaderWriter;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the live <see cref="CharacterSheet"/> for the retail Character
|
||||
/// window and owns the raise-request flow (wire send + optimistic local
|
||||
/// apply). Extracted from <c>GameWindow</c> (Code Structure Rule 1: sheet
|
||||
/// assembly + XP-curve math is feature logic, not wiring).
|
||||
///
|
||||
/// <para>Retail property ids and the decomp anchors for every field are
|
||||
/// documented on <see cref="CharacterSheet"/>; the raise-cost formulas are
|
||||
/// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family).</para>
|
||||
///
|
||||
/// <para><b>State ownership:</b> optimistic debits go through the owning
|
||||
/// store's eventful APIs — <see cref="ClientObjectTable.UpdateIntProperty"/> /
|
||||
/// <see cref="ClientObjectTable.UpdateInt64Property"/> (fires ObjectUpdated)
|
||||
/// when the player object is in the table, else
|
||||
/// <see cref="LocalPlayerState.DebitIntProperty"/> /
|
||||
/// <see cref="LocalPlayerState.DebitInt64Property"/> (fires CharacterChanged).
|
||||
/// Never write the raw property dictionaries from UI code. The next server
|
||||
/// snapshot remains authoritative over every optimistic value.</para>
|
||||
/// </summary>
|
||||
public sealed class CharacterSheetProvider
|
||||
{
|
||||
/// <summary>PropertyInt64 2 = unassigned (banked) XP — CharacterSheet.UnassignedXp.</summary>
|
||||
private const uint UnassignedXpPropertyId = 2u;
|
||||
|
||||
/// <summary>
|
||||
/// Skill-credit debit fallback chain, most-specific first. Retail reads
|
||||
/// InqInt(0x18) for the Attributes-tab footer, InqInt(0xc0) for available
|
||||
/// and InqInt(0xb5) for total skill credits (see CharacterSheet docs);
|
||||
/// the live server population varies, so debit whichever is present.
|
||||
/// </summary>
|
||||
private static readonly uint[] SkillCreditPropertyIds = { 0x18u, 0xC0u, 0xB5u };
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly LocalPlayerState _localPlayer;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<string?>? _activeToonName;
|
||||
private readonly Func<string, CharacterSheet>? _fallbackSheet;
|
||||
private readonly Func<bool>? _canSendRaise;
|
||||
private readonly Action<uint, ulong>? _sendRaiseAttribute;
|
||||
private readonly Action<uint, ulong>? _sendRaiseVital;
|
||||
private readonly Action<uint, ulong>? _sendRaiseSkill;
|
||||
private readonly Action<uint, uint>? _sendTrainSkill;
|
||||
|
||||
/// <summary>Portal SkillTable (0x0E000004) — set by the host once dats load.</summary>
|
||||
public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; }
|
||||
|
||||
/// <summary>Portal ExperienceTable (0x0E000018) — set by the host once dats load.</summary>
|
||||
public DatReaderWriter.DBObjs.ExperienceTable? ExperienceTable { get; set; }
|
||||
|
||||
public CharacterSheetProvider(
|
||||
ClientObjectTable objects,
|
||||
LocalPlayerState localPlayer,
|
||||
Func<uint> playerGuid,
|
||||
Func<string?>? activeToonName = null,
|
||||
Func<string, CharacterSheet>? fallbackSheet = null,
|
||||
Func<bool>? canSendRaise = null,
|
||||
Action<uint, ulong>? sendRaiseAttribute = null,
|
||||
Action<uint, ulong>? sendRaiseVital = null,
|
||||
Action<uint, ulong>? sendRaiseSkill = null,
|
||||
Action<uint, uint>? sendTrainSkill = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_activeToonName = activeToonName;
|
||||
_fallbackSheet = fallbackSheet;
|
||||
_canSendRaise = canSendRaise;
|
||||
_sendRaiseAttribute = sendRaiseAttribute;
|
||||
_sendRaiseVital = sendRaiseVital;
|
||||
_sendRaiseSkill = sendRaiseSkill;
|
||||
_sendTrainSkill = sendTrainSkill;
|
||||
}
|
||||
|
||||
// ── Sheet assembly ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Best display name: active toon key, else the live object's name, else "Player".</summary>
|
||||
public string CharacterName()
|
||||
{
|
||||
string? toon = _activeToonName?.Invoke();
|
||||
if (!string.IsNullOrWhiteSpace(toon) && toon != "default")
|
||||
return toon;
|
||||
uint guid = _playerGuid();
|
||||
if (guid != 0u && _objects.Get(guid)?.Name is { Length: > 0 } objectName)
|
||||
return objectName;
|
||||
return "Player";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the sheet from live state; falls back to the injected sample
|
||||
/// sheet (Studio data) until any live character data has arrived.
|
||||
/// Called per repaint by the Character window's bound widgets.
|
||||
/// </summary>
|
||||
public CharacterSheet BuildSheet()
|
||||
{
|
||||
if (!HasLiveData())
|
||||
return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() };
|
||||
|
||||
var props = CurrentPlayerProperties();
|
||||
int level = props.GetInt(0x19u);
|
||||
long totalXp = props.GetInt64(1u);
|
||||
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
|
||||
var xp = ComputeLevelXp(level, totalXp);
|
||||
|
||||
int skillCredits = props.GetInt(0x18u, props.GetInt(0xC0u, props.GetInt(0xB5u)));
|
||||
|
||||
return new CharacterSheet
|
||||
{
|
||||
Name = CharacterName(),
|
||||
Level = level,
|
||||
Gender = CharacterIdentityText.GenderDisplayName(
|
||||
props.GetInt(CharacterIdentityText.GenderPropertyId)),
|
||||
Heritage = CharacterIdentityText.HeritageGroupDisplayName(
|
||||
props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)),
|
||||
PkStatus = PkStatusText(props.GetInt(134u, 0)),
|
||||
TotalXp = totalXp,
|
||||
XpToNextLevel = xp.toNext,
|
||||
XpFraction = xp.fraction,
|
||||
|
||||
HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health),
|
||||
HealthMax = VitalMax(LocalPlayerState.VitalKind.Health),
|
||||
StaminaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Stamina),
|
||||
StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina),
|
||||
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
|
||||
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
|
||||
|
||||
Strength = AttrCurrent(LocalPlayerState.AttributeKind.Strength),
|
||||
Endurance = AttrCurrent(LocalPlayerState.AttributeKind.Endurance),
|
||||
Coordination = AttrCurrent(LocalPlayerState.AttributeKind.Coordination),
|
||||
Quickness = AttrCurrent(LocalPlayerState.AttributeKind.Quickness),
|
||||
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
|
||||
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
|
||||
|
||||
UnspentSkillCredits = props.GetInt(0xB5u, skillCredits),
|
||||
SpecializedSkillCredits = props.GetInt(0xC0u),
|
||||
SkillCredits = skillCredits,
|
||||
UnassignedXp = unassignedXp,
|
||||
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
|
||||
AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10),
|
||||
Skills = BuildLiveCharacterSkills(),
|
||||
BurdenCurrent = props.GetInt(5u),
|
||||
BurdenMax = props.GetInt(96u),
|
||||
};
|
||||
}
|
||||
|
||||
private bool HasLiveData()
|
||||
{
|
||||
var props = CurrentPlayerProperties();
|
||||
return props.Ints.Count > 0
|
||||
|| props.Int64s.Count > 0
|
||||
|| _localPlayer.Skills.Count > 0
|
||||
|| _localPlayer.GetAttribute(LocalPlayerState.AttributeKind.Strength) is not null
|
||||
|| _localPlayer.Get(LocalPlayerState.VitalKind.Health) is not null;
|
||||
}
|
||||
|
||||
/// <summary>The player's canonical bundle: the live ClientObject's when the
|
||||
/// player object has arrived (CreateObject merge-upsert), else the
|
||||
/// PlayerDescription snapshot on LocalPlayerState.</summary>
|
||||
private PropertyBundle CurrentPlayerProperties()
|
||||
{
|
||||
uint guid = _playerGuid();
|
||||
return guid != 0u && _objects.Get(guid) is { } player
|
||||
? player.Properties
|
||||
: _localPlayer.Properties;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the portal ExperienceTable (0x0E000018), falling back to a
|
||||
/// type scan for older or odd dat collections. Failures are logged —
|
||||
/// never silently swallowed — and leave raise costs unavailable (0).
|
||||
/// </summary>
|
||||
public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable(
|
||||
DatCollection dats, Action<string>? log = null)
|
||||
{
|
||||
if (dats is null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(0x0E000018u);
|
||||
if (table is not null) return table;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"[D.2b-C] ExperienceTable 0x0E000018 read failed ({ex.GetType().Name}: {ex.Message}); trying type scan.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (uint id in dats.GetAllIdsOfType<DatReaderWriter.DBObjs.ExperienceTable>())
|
||||
{
|
||||
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(id);
|
||||
if (table is not null) return table;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"[D.2b-C] ExperienceTable type scan failed ({ex.GetType().Name}: {ex.Message}); raise costs unavailable.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>XP still needed for the next level + fill fraction of the
|
||||
/// current level band (retail (cur−base)/(cap−base); CharacterSheet.XpFraction).</summary>
|
||||
private (long toNext, float fraction) ComputeLevelXp(int level, long totalXp)
|
||||
{
|
||||
var levels = ExperienceTable?.Levels;
|
||||
if (levels is null || level < 0 || level + 1 >= levels.Length)
|
||||
return (0L, 0f);
|
||||
|
||||
long current = ClampToLong(levels[level]);
|
||||
long next = ClampToLong(levels[level + 1]);
|
||||
if (next <= current) return (0L, 0f);
|
||||
|
||||
long clampedXp = totalXp < current ? current : totalXp > next ? next : totalXp;
|
||||
long toNext = next - clampedXp;
|
||||
float fraction = (float)(clampedXp - current) / (next - current);
|
||||
return (toNext, fraction);
|
||||
}
|
||||
|
||||
/// <summary>Per-attribute/vital raise costs in retail display order
|
||||
/// (see CharacterSheet.AttributeRaiseCosts). Cost 0 = maxed / unknown.</summary>
|
||||
private long[] BuildAttributeRaiseCosts(int amount)
|
||||
{
|
||||
var xp = ExperienceTable;
|
||||
return new[]
|
||||
{
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Strength),
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Endurance),
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Coordination),
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Quickness),
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Focus),
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Self),
|
||||
VitalRaiseCost(LocalPlayerState.VitalKind.Health),
|
||||
VitalRaiseCost(LocalPlayerState.VitalKind.Stamina),
|
||||
VitalRaiseCost(LocalPlayerState.VitalKind.Mana),
|
||||
};
|
||||
|
||||
long AttributeRaiseCost(LocalPlayerState.AttributeKind kind)
|
||||
{
|
||||
var attr = _localPlayer.GetAttribute(kind);
|
||||
return attr is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Attributes, attr.Value.Ranks, attr.Value.Xp, amount);
|
||||
}
|
||||
|
||||
long VitalRaiseCost(LocalPlayerState.VitalKind kind)
|
||||
{
|
||||
var vital = _localPlayer.Get(kind);
|
||||
return vital is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Vitals, vital.Value.Ranks, vital.Value.Xp, amount);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills()
|
||||
{
|
||||
var result = new List<CharacterSkill>();
|
||||
var skillTable = SkillTable;
|
||||
var xp = ExperienceTable;
|
||||
|
||||
foreach (var snapshot in _localPlayer.Skills.Values)
|
||||
{
|
||||
var advancement = AdvancementFromStatus(snapshot.Status);
|
||||
if (advancement == CharacterSkillAdvancementClass.Inactive)
|
||||
continue;
|
||||
|
||||
DatReaderWriter.Types.SkillBase? skillBase = null;
|
||||
if (skillTable?.Skills is not null)
|
||||
skillTable.Skills.TryGetValue((DatReaderWriter.Enums.SkillId)snapshot.SkillId, out skillBase);
|
||||
|
||||
string? name = skillBase?.Name.Value;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
name = $"Skill {snapshot.SkillId}";
|
||||
|
||||
uint icon = skillBase?.IconId.DataId ?? 0u;
|
||||
int trainedCost = skillBase?.TrainedCost ?? 0;
|
||||
int specializedCost = skillBase?.SpecializedCost ?? 0;
|
||||
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
|
||||
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
|
||||
|
||||
result.Add(new CharacterSkill(
|
||||
snapshot.SkillId,
|
||||
name,
|
||||
icon,
|
||||
advancement,
|
||||
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)),
|
||||
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
|
||||
IsUsableUntrained(snapshot.SkillId),
|
||||
trainedCost,
|
||||
specializedCost,
|
||||
raiseCost,
|
||||
raise10Cost));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static CharacterSkillAdvancementClass AdvancementFromStatus(uint status) => status switch
|
||||
{
|
||||
1u => CharacterSkillAdvancementClass.Untrained,
|
||||
2u => CharacterSkillAdvancementClass.Trained,
|
||||
3u => CharacterSkillAdvancementClass.Specialized,
|
||||
_ => CharacterSkillAdvancementClass.Inactive,
|
||||
};
|
||||
|
||||
private static bool IsUsableUntrained(uint skillId) => skillId switch
|
||||
{
|
||||
18u or 37u or 38u or 39u or 40u => false,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
private static long SkillRaiseCost(
|
||||
DatReaderWriter.DBObjs.ExperienceTable? xp,
|
||||
CharacterSkillAdvancementClass advancement,
|
||||
LocalPlayerState.SkillSnapshot skill,
|
||||
int amount)
|
||||
{
|
||||
if (xp is null) return 0L;
|
||||
uint[] curve = advancement == CharacterSkillAdvancementClass.Specialized
|
||||
? xp.SpecializedSkills
|
||||
: xp.TrainedSkills;
|
||||
return RaiseCostFromXpCurve(curve, skill.Ranks, skill.Xp, amount);
|
||||
}
|
||||
|
||||
/// <summary>Cost to advance <paramref name="amount"/> ranks along a retail
|
||||
/// cumulative-XP curve: curve[target] − xpAlreadySpent, clamped at the
|
||||
/// curve end (retail GetCostToRaise/GetCostToRaise10 0x0049cb80/0x0049cc70).</summary>
|
||||
private static long RaiseCostFromXpCurve(uint[]? curve, uint ranks, uint spentXp, int amount)
|
||||
{
|
||||
if (curve is null || amount <= 0) return 0L;
|
||||
long maxIndex = curve.Length - 1L;
|
||||
if (maxIndex <= ranks) return 0L;
|
||||
long targetLong = Math.Min((long)ranks + amount, maxIndex);
|
||||
long targetXp = curve[(int)targetLong];
|
||||
long cost = targetXp - spentXp;
|
||||
return cost > 0 ? cost : 0L;
|
||||
}
|
||||
|
||||
private static long ClampToLong(ulong value) =>
|
||||
value > long.MaxValue ? long.MaxValue : (long)value;
|
||||
|
||||
private static string? PkStatusText(int status) => status switch
|
||||
{
|
||||
0x2 => "Non-Player Killer",
|
||||
0x4 => "Player Killer",
|
||||
0x40 => "Player Killer Lite",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private int AttrCurrent(LocalPlayerState.AttributeKind kind) =>
|
||||
_localPlayer.GetAttribute(kind) is { } attr ? checked((int)Math.Min(int.MaxValue, attr.Current)) : 0;
|
||||
|
||||
private int VitalCurrent(LocalPlayerState.VitalKind kind) =>
|
||||
_localPlayer.Get(kind) is { } vital ? checked((int)Math.Min(int.MaxValue, vital.Current)) : 0;
|
||||
|
||||
private int VitalMax(LocalPlayerState.VitalKind kind) =>
|
||||
_localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0;
|
||||
|
||||
// ── Raise-request flow ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Send a raise/train action to the server and, when a send delegate
|
||||
/// fired, optimistically apply the local effect so the sheet stays
|
||||
/// current during the round trip. The next server snapshot remains
|
||||
/// authoritative (a rejected raise is corrected by the property echo —
|
||||
/// pending/rollback ledger tracked as a follow-up issue).
|
||||
/// </summary>
|
||||
public void HandleRaiseRequest(CharacterStatController.RaiseRequest request)
|
||||
{
|
||||
if (request.Cost <= 0) return;
|
||||
if (_canSendRaise is not null && !_canSendRaise()) return;
|
||||
|
||||
bool sent = false;
|
||||
switch (request.Kind)
|
||||
{
|
||||
case CharacterStatController.RaiseTargetKind.Attribute:
|
||||
if (_sendRaiseAttribute is not null)
|
||||
{
|
||||
_sendRaiseAttribute(request.StatId, (ulong)request.Cost);
|
||||
sent = true;
|
||||
}
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Vital:
|
||||
if (_sendRaiseVital is not null)
|
||||
{
|
||||
_sendRaiseVital(request.StatId, (ulong)request.Cost);
|
||||
sent = true;
|
||||
}
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Skill:
|
||||
if (_sendRaiseSkill is not null)
|
||||
{
|
||||
_sendRaiseSkill(request.StatId, (ulong)request.Cost);
|
||||
sent = true;
|
||||
}
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.TrainSkill:
|
||||
if (_sendTrainSkill is not null && request.Cost <= uint.MaxValue)
|
||||
{
|
||||
_sendTrainSkill(request.StatId, (uint)request.Cost);
|
||||
sent = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (sent)
|
||||
ApplyLocalRaise(request);
|
||||
}
|
||||
|
||||
private void ApplyLocalRaise(CharacterStatController.RaiseRequest request)
|
||||
{
|
||||
uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount;
|
||||
ulong cost = (ulong)request.Cost;
|
||||
|
||||
switch (request.Kind)
|
||||
{
|
||||
case CharacterStatController.RaiseTargetKind.Attribute:
|
||||
if (_localPlayer.ApplyAttributeRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Vital:
|
||||
if (_localPlayer.ApplyVitalRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Skill:
|
||||
if (_localPlayer.ApplySkillRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.TrainSkill:
|
||||
if (_localPlayer.ApplySkillTraining(request.StatId))
|
||||
SpendSkillCredits(request.Cost);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SpendUnassignedExperience(long cost)
|
||||
{
|
||||
if (cost <= 0) return;
|
||||
uint guid = _playerGuid();
|
||||
if (guid != 0u && _objects.Get(guid) is { } player)
|
||||
{
|
||||
long current = player.Properties.GetInt64(UnassignedXpPropertyId);
|
||||
if (current <= 0) return;
|
||||
_objects.UpdateInt64Property(guid, UnassignedXpPropertyId,
|
||||
current > cost ? current - cost : 0L);
|
||||
return;
|
||||
}
|
||||
_localPlayer.DebitInt64Property(UnassignedXpPropertyId, cost);
|
||||
}
|
||||
|
||||
private void SpendSkillCredits(long cost)
|
||||
{
|
||||
if (cost <= 0) return;
|
||||
int debit = cost > int.MaxValue ? int.MaxValue : (int)cost;
|
||||
uint guid = _playerGuid();
|
||||
if (guid != 0u && _objects.Get(guid) is { } player)
|
||||
{
|
||||
foreach (uint propertyId in SkillCreditPropertyIds)
|
||||
{
|
||||
if (!player.Properties.Ints.TryGetValue(propertyId, out int current))
|
||||
continue;
|
||||
_objects.UpdateIntProperty(guid, propertyId,
|
||||
current > debit ? current - debit : 0);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
foreach (uint propertyId in SkillCreditPropertyIds)
|
||||
{
|
||||
if (_localPlayer.DebitIntProperty(propertyId, debit))
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue