using System; using System.Collections.Generic; using AcDream.Core.Items; using AcDream.Core.Player; using DatReaderWriter; using AcDream.Content; namespace AcDream.App.UI.Layout; /// /// Assembles the live for the retail Character /// window and owns the raise-request flow (wire send + optimistic local /// apply). Extracted from GameWindow (Code Structure Rule 1: sheet /// assembly + XP-curve math is feature logic, not wiring). /// /// Retail property ids and the decomp anchors for every field are /// documented on ; the raise-cost formulas are /// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family). /// /// State ownership: optimistic debits go through the owning /// store's eventful APIs — / /// (fires ObjectUpdated) /// when the player object is in the table, else /// / /// (fires CharacterChanged). /// Never write the raw property dictionaries from UI code. The next server /// snapshot remains authoritative over every optimistic value. /// public sealed class CharacterSheetProvider { /// PropertyInt64 2 = unassigned (banked) XP — CharacterSheet.UnassignedXp. private const uint UnassignedXpPropertyId = 2u; /// /// Retail PropertyInt 0x18 = available skill credits. Properties 0xB5 and /// 0xC0 are Chess Rank and Fishing Skill and must never be debited. /// private static readonly uint[] SkillCreditPropertyIds = { 0x18u }; private readonly ClientObjectTable _objects; private readonly LocalPlayerState _localPlayer; private readonly Func _playerGuid; private readonly Func? _activeToonName; private readonly Func? _fallbackSheet; private readonly Func? _canSendRaise; private readonly Action? _sendRaiseAttribute; private readonly Action? _sendRaiseVital; private readonly Action? _sendRaiseSkill; private readonly Action? _sendTrainSkill; /// Portal SkillTable (0x0E000004) — set by the host once dats load. public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; } /// Portal ExperienceTable (0x0E000018) — set by the host once dats load. public DatReaderWriter.DBObjs.ExperienceTable? ExperienceTable { get; set; } public CharacterSheetProvider( ClientObjectTable objects, LocalPlayerState localPlayer, Func playerGuid, Func? activeToonName = null, Func? fallbackSheet = null, Func? canSendRaise = null, Action? sendRaiseAttribute = null, Action? sendRaiseVital = null, Action? sendRaiseSkill = null, Action? 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 ───────────────────────────────────────────────────── /// Best display name: active toon key, else the live object's name, else "Player". 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"; } /// /// 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. /// 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); 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 = skillCredits, SpecializedSkillCredits = 0, ChessRank = props.GetInt(0xB5u), FishingSkill = props.GetInt(0xC0u), BirthTimestamp = props.Ints.TryGetValue(0x62u, out int born) ? born : null, TotalPlayTimeSeconds = props.Ints.TryGetValue(0x7Du, out int played) ? played : null, Deaths = props.GetInt(0x2Bu), SkillCredits = skillCredits, UnassignedXp = unassignedXp, AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1), AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10), Skills = BuildLiveCharacterSkills(), BurdenCurrent = props.GetInt(5u), BurdenMax = props.GetInt(96u), EncumbranceAugmentations = props.GetInt(0xE6u), CharacterInfoProperties = new Dictionary(props.Ints), }; } 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; } /// The player's canonical bundle: the live ClientObject's when the /// player object has arrived (CreateObject merge-upsert), else the /// PlayerDescription snapshot on LocalPlayerState. private PropertyBundle CurrentPlayerProperties() { uint guid = _playerGuid(); return guid != 0u && _objects.Get(guid) is { } player ? player.Properties : _localPlayer.Properties; } /// /// 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). /// public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable( IDatReaderWriter dats, Action? log = null) { if (dats is null) return null; try { var table = dats.Get(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()) { var table = dats.Get(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; } /// XP still needed for the next level + fill fraction of the /// current level band (retail (cur−base)/(cap−base); CharacterSheet.XpFraction). 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); } /// Per-attribute/vital raise costs in retail display order /// (see CharacterSheet.AttributeRaiseCosts). Cost 0 = maxed / unknown. 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 BuildLiveCharacterSkills() { var result = new List(); 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); } /// Cost to advance ranks along a retail /// cumulative-XP curve: curve[target] − xpAlreadySpent, clamped at the /// curve end (retail GetCostToRaise/GetCostToRaise10 0x0049cb80/0x0049cc70). 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 ───────────────────────────────────────────────── /// /// 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). /// 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; } } }