acdream/src/AcDream.Core/Player/LocalPlayerState.cs
Erik 1bd2b30291
All checks were successful
CI / linux-portable (push) Successful in 3m16s
CI / windows-gate (push) Successful in 5m41s
CI / release (push) Successful in 2m5s
fix(ui): restore retail vitals and window interactions
2026-08-20 13:26:35 +02:00

680 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
namespace AcDream.Core.Player;
/// <summary>
/// Local player's attribute + vital snapshot, populated from the
/// <c>PlayerDescription (0x0013)</c> attribute block at login and
/// kept fresh by <c>PrivateUpdateVital (0x02E7)</c> +
/// <c>PrivateUpdateVitalCurrent (0x02E9)</c> deltas.
///
/// <para>
/// Wire format references:
/// </para>
/// <list type="bullet">
/// <item>holtburger
/// <c>crates/holtburger-protocol/src/messages/player/events.rs</c>
/// for PlayerDescription body layout (vitals at attribute-block
/// ids 7/8/9 with <c>ranks/start/xp/current</c>).</item>
/// <item>holtburger
/// <c>crates/holtburger-world/src/player/stats_calc.rs</c>
/// <c>calculate_vital_current</c> for the max formula —
/// <c>(ranks + start + attribute_contribution) × multiplier + additive</c>.
/// We implement the unenchanted base case with retail-faithful
/// hardcoded attribute coefficients (no portal.dat
/// <c>SecondaryAttributeTable</c> port yet — see remarks).</item>
/// </list>
///
/// <para>
/// <b>Max derivation</b> (retail base case, no enchantments):
/// </para>
/// <code>
/// MaxHealth = vital.ranks + vital.start + Endurance.current / 2
/// MaxStamina = vital.ranks + vital.start + Endurance.current
/// MaxMana = vital.ranks + vital.start + Self.current
/// </code>
///
/// <para>
/// Primary attribute <c>current = ranks + start</c> per holtburger
/// <c>mutations.rs</c>. Attribute coefficients come from retail's
/// <c>SecondaryAttributeTable</c> (portal.dat 0x0E0..0x0E2). The
/// values are hardcoded here as well-known constants; a future port
/// of the dat object can replace the hardcodes if those coefficients
/// ever turn out to vary.
/// </para>
///
/// <para>
/// <b>Enchantment buffs</b> (multiplicative + additive), vitae, and the
/// retail five-point minimum are applied through the attached
/// <see cref="AcDream.Core.Spells.Spellbook"/>. Base-value accessors retain
/// the unenchanted values needed by the character-panel comparison logic.
/// </para>
/// </summary>
public sealed class LocalPlayerState
{
/// <summary>Three vital types — mirrors holtburger <c>VitalType</c>.</summary>
public enum VitalKind
{
Health,
Stamina,
Mana,
}
/// <summary>Six primary attributes — ACE <c>PropertyAttribute</c>.</summary>
public enum AttributeKind
{
Strength,
Endurance,
Quickness,
Coordination,
Focus,
Self,
}
/// <summary>Primary-attribute snapshot. <c>Current = Ranks + Start</c>
/// per retail; we don't track an independent "current attribute"
/// because PlayerDescription doesn't expose one.</summary>
public readonly record struct AttributeSnapshot(uint Ranks, uint Start, uint Xp)
{
public uint Current => Ranks + Start;
}
/// <summary>Per-vital snapshot. Max comes from
/// <see cref="LocalPlayerState.GetMaxApprox"/> because it depends
/// 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 readonly Dictionary<uint, Position> _positions = new();
private PropertyBundle _properties = new();
private readonly Spellbook? _spellbook;
/// <summary>
/// Build a LocalPlayerState. Optional <see cref="Spellbook"/>
/// reference unlocks issue #6 — vital-max calc folds in active
/// enchantment buffs via <see cref="Spellbook.GetVitalMod"/>. When
/// absent (back-compat for tests / older callers), buff modifiers
/// are skipped (identity).
/// </summary>
public LocalPlayerState(Spellbook? spellbook = null)
{
_spellbook = spellbook;
}
/// <summary>Fires after any vital field changes.</summary>
public event System.Action<VitalKind>? Changed;
/// <summary>Fires after any primary-attribute field changes (rare —
/// 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">
/// <item><c>1..=6</c> — wire-opcode <c>Vital</c> enum
/// (MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6).</item>
/// <item><c>7..=9</c> — PlayerDescription attribute-block ids
/// (Health=7, Stamina=8, Mana=9).</item>
/// </list>
/// </summary>
public static VitalKind? VitalIdToKind(uint vitalId) => vitalId switch
{
1u or 2u or 7u => VitalKind.Health,
3u or 4u or 8u => VitalKind.Stamina,
5u or 6u or 9u => VitalKind.Mana,
_ => null,
};
/// <summary>
/// Map a primary-attribute id (1..=6) to <see cref="AttributeKind"/>.
/// Returns <c>null</c> for ids outside that range — vital ids 7-9
/// don't map.
/// </summary>
public static AttributeKind? AttributeIdToKind(uint atType) => atType switch
{
1u => AttributeKind.Strength,
2u => AttributeKind.Endurance,
3u => AttributeKind.Quickness,
4u => AttributeKind.Coordination,
5u => AttributeKind.Focus,
6u => AttributeKind.Self,
_ => null,
};
/// <summary>Snapshot for a vital, or <c>null</c> if never received.</summary>
public VitalSnapshot? Get(VitalKind kind) => kind switch
{
VitalKind.Health => _health,
VitalKind.Stamina => _stamina,
VitalKind.Mana => _mana,
_ => null,
};
/// <summary>Snapshot for a primary attribute, or <c>null</c> if never received.</summary>
public AttributeSnapshot? GetAttribute(AttributeKind kind) =>
_attrs.TryGetValue(kind, out var a) ? a : null;
/// <summary>The optional wired spellbook — read-only exposure of the same
/// reference passed to the constructor. Issue #267: lets presentation
/// code (e.g. <c>CharacterSheetProvider</c>) subscribe to
/// <see cref="Spells.Spellbook.EnchantmentsChanged"/> for live character-sheet
/// refresh without duplicating the reference in its own constructor.</summary>
public Spellbook? Spellbook => _spellbook;
/// <summary>
/// Issue #267 — effective (post-buff) primary-attribute value. Retail
/// <c>CACQualities::EnchantAttribute</c> (0x00594570): primary attributes
/// do NOT receive vitae (only <see cref="GetMaxApprox"/>'s secondary
/// attributes and <see cref="GetEffectiveSkill"/>'s skills do). Returns
/// the unenchanted base when no spellbook is wired (back-compat) or the
/// attribute hasn't arrived yet.
/// </summary>
public int? GetEffectiveAttribute(AttributeKind kind)
{
AttributeSnapshot? attr = GetAttribute(kind);
if (attr is null) return null;
uint baseValue = attr.Value.Current;
if (_spellbook is null) return (int)baseValue;
EnchantmentMath.VitalMod mod = _spellbook.GetAttributeMod(AttributeKindToId(kind));
return EnchantmentMath.EnchantAttribute(mod, baseValue);
}
/// <summary>Reverse of <see cref="AttributeIdToKind"/> — the wire
/// PropertyAttribute id (1..6) for a given <see cref="AttributeKind"/>.</summary>
public static uint AttributeKindToId(AttributeKind kind) => kind switch
{
AttributeKind.Strength => 1u,
AttributeKind.Endurance => 2u,
AttributeKind.Quickness => 3u,
AttributeKind.Coordination => 4u,
AttributeKind.Focus => 5u,
AttributeKind.Self => 6u,
_ => 0u,
};
/// <summary>
/// Issue #267 — effective (post-vitae, post-buff) skill level. Retail
/// <c>CACQualities::EnchantSkill</c> (0x005947b0). Returns the
/// unenchanted base when no spellbook is wired (back-compat) or the
/// skill hasn't arrived yet.
/// </summary>
public int? GetEffectiveSkill(uint skillId)
=> GetSkillValue(skillId)?.EffectiveLevel;
/// <summary>
/// Full retail <c>CACQualities::InqSkill</c> projection, including the
/// augmentation terms on both sides of <c>EnchantSkill</c>. Callers with a
/// fresher player-object property bundle may supply it; otherwise the
/// PlayerDescription snapshot is used.
/// </summary>
public PlayerSkillMath.Value? GetSkillValue(
uint skillId,
PropertyBundle? properties = null)
{
SkillSnapshot? skill = GetSkill(skillId);
if (skill is null) return null;
PlayerSkillMath.AugmentationBonuses augmentations =
PlayerSkillMath.AugmentationBonuses.FromProperties(
properties ?? _properties);
EnchantmentMath.VitalMod mod = _spellbook?.GetSkillMod(skillId)
?? EnchantmentMath.VitalMod.Identity;
float vitae = _spellbook is null
? 1f
: EnchantmentMath.GetVitaeMultiplier(
_spellbook.ActiveEnchantments);
return PlayerSkillMath.Calculate(
checked((int)Math.Min(int.MaxValue, skill.Value.CurrentLevel)),
skillId,
skill.Value.Status,
augmentations,
mod,
vitae);
}
/// <summary>
/// Issue #267 — vitae's isolated contribution to a skill's effective
/// level (retail <c>SkillInfoRegion::GetVitaeModifier</c> 0x004f0fa0),
/// separate from any buff delta. Always ≤ 0. Returns 0 when no spellbook
/// is wired, no vitae is active, or the skill hasn't arrived yet.
/// </summary>
public int GetSkillVitaeModifier(uint skillId)
=> GetSkillValue(skillId)?.VitaeModifier ?? 0;
/// <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>Player Position qualities keyed by retail PositionType.</summary>
public IReadOnlyDictionary<uint, Position> Positions => _positions;
public Position? GetPosition(uint positionType) =>
_positions.TryGetValue(positionType, out var value) ? value : null;
public void OnPositions(IReadOnlyDictionary<uint, Position> positions)
{
_positions.Clear();
foreach (var pair in positions)
_positions[pair.Key] = pair.Value;
CharacterChanged?.Invoke();
}
/// <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>
/// with a <c>>= 5 if base >= 5 else >= 1</c> minimum-vital clamp
/// (matches <c>CreatureVital::GetMaxValue</c> at PDB
/// <c>0x0058F2DD</c>). Buffs are pulled from the optional
/// <see cref="Spellbook"/> via <see cref="EnchantmentMath.GetMod"/>;
/// when absent, returns the unenchanted max.
///
/// <para>
/// Returns <c>null</c> if the vital snapshot doesn't exist yet.
/// </para>
/// </summary>
public uint? GetMaxApprox(VitalKind kind)
{
uint? baseValue = GetMaxBeforeSecondaryEnchantments(kind);
if (baseValue is not uint unbuffed) return null;
// Preserve the "no data" sentinel — when the unbuffed max is 0
// we lack the inputs to compute anything reasonable. The retail
// min-vital floor only kicks in once we know the base.
if (unbuffed == 0) return 0;
var mod = _spellbook?.GetVitalMod(StatKeyForKind(kind))
?? EnchantmentMath.VitalMod.Identity;
// Apply: (unbuffed * mult) + additive, then clamp to retail's
// min-vital floor (5 if base >= 5 else 1). The final cast is
// retail CEnchantmentRegistry::EnchantAttribute2nd's _ftol2
// conversion at 0x00594787: truncate toward zero, do not round.
float buffed = (unbuffed * mod.Multiplier) + mod.Additive;
uint minFloor = unbuffed >= 5 ? 5u : 1u;
if (buffed < minFloor) buffed = minFloor;
return (uint)buffed;
}
/// <summary>
/// Unenchanted secondary-attribute maximum used by retail's
/// <c>Attribute2ndInfoRegion::Update @ 0x004F19E0</c> comparison.
/// </summary>
public uint? GetBaseMaxApprox(VitalKind kind)
{
VitalSnapshot? vital = Get(kind);
if (vital is null) return null;
return vital.Value.Ranks
+ vital.Value.Start
+ AttributeContribution(kind, effective: false)
+ GearHealthBonus(kind);
}
/// <summary>
/// Retail <c>CACQualities::InqAttribute2nd @ 0x00592020</c> computes a
/// maximum vital's formula contribution through <c>InqAttribute</c> with
/// enchantments enabled, adds <c>GearMaxHealth</c> (property 379) for
/// health, and only then calls <c>EnchantAttribute2nd</c>. Keeping this
/// stage separate prevents primary-attribute records from leaking into
/// the secondary-attribute modifier while still allowing buffed
/// Endurance/Self to feed the vital formula.
/// </summary>
private uint? GetMaxBeforeSecondaryEnchantments(VitalKind kind)
{
VitalSnapshot? vital = Get(kind);
if (vital is null) return null;
return vital.Value.Ranks
+ vital.Value.Start
+ AttributeContribution(kind, effective: true)
+ GearHealthBonus(kind);
}
/// <summary>
/// Isolated vitae contribution to a secondary attribute, matching
/// <c>Attribute2ndInfoRegion::GetVitaeModifier @ 0x004F1130</c>.
/// </summary>
public int GetVitalVitaeModifier(VitalKind kind)
{
if (_spellbook is null
|| GetBaseMaxApprox(kind) is not uint baseValue)
{
return 0;
}
return EnchantmentMath.SkillVitaeModifier(
EnchantmentMath.GetVitaeMultiplier(
_spellbook.ActiveEnchantments),
baseValue);
}
private static uint StatKeyForKind(VitalKind kind) => kind switch
{
VitalKind.Health => EnchantmentMath.StatKey.MaxHealth,
VitalKind.Stamina => EnchantmentMath.StatKey.MaxStamina,
VitalKind.Mana => EnchantmentMath.StatKey.MaxMana,
_ => 0u,
};
/// <summary>Stamina percent (0..1) or null when not yet received.</summary>
public float? StaminaPercent => Percent(VitalKind.Stamina);
/// <summary>Mana percent (0..1) or null when not yet received.</summary>
public float? ManaPercent => Percent(VitalKind.Mana);
/// <summary>Health percent (0..1) or null when not yet received.</summary>
public float? HealthPercent => Percent(VitalKind.Health);
private float? Percent(VitalKind kind)
{
var v = Get(kind);
if (v is null) return null;
uint? max = GetMaxApprox(kind);
if (max is not uint m || m == 0) return null;
float r = (float)v.Value.Current / m;
if (r < 0f) r = 0f;
else if (r > 1f) r = 1f;
return r;
}
/// <summary>
/// Apply a full vital update — replaces ranks / start / xp / current
/// for the matching <see cref="VitalKind"/>. Accepts both wire-opcode
/// ids (1..=6) and PlayerDescription attribute-block ids (7..=9).
/// </summary>
public void OnVitalUpdate(uint vitalId, uint ranks, uint start, uint xp, uint current)
{
if (VitalIdToKind(vitalId) is not VitalKind kind) return;
var snap = new VitalSnapshot(ranks, start, xp, current);
switch (kind)
{
case VitalKind.Health: _health = snap; break;
case VitalKind.Stamina: _stamina = snap; break;
case VitalKind.Mana: _mana = snap; break;
}
Changed?.Invoke(kind);
}
/// <summary>
/// Apply a current-only delta. Silently ignored if no full update
/// has been received for this vital yet (matches holtburger's
/// <c>get_mut(&amp;kind)</c> miss-as-noop semantics).
/// </summary>
public void OnVitalCurrent(uint vitalId, uint current)
{
if (VitalIdToKind(vitalId) is not VitalKind kind) return;
VitalSnapshot? existing = Get(kind);
if (existing is not VitalSnapshot prev) return;
var snap = prev with { Current = current };
switch (kind)
{
case VitalKind.Health: _health = snap; break;
case VitalKind.Stamina: _stamina = snap; break;
case VitalKind.Mana: _mana = snap; break;
}
Changed?.Invoke(kind);
}
/// <summary>
/// Apply a primary-attribute update from PlayerDescription's
/// attribute block (ids 1..=6). Vital ids (7..=9) here are silently
/// dropped — feed them through <see cref="OnVitalUpdate"/> instead.
/// </summary>
public void OnAttributeUpdate(uint atType, uint ranks, uint start, uint xp)
{
if (AttributeIdToKind(atType) is not AttributeKind kind) return;
_attrs[kind] = new AttributeSnapshot(ranks, start, xp);
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 one authoritative signed 64-bit player-quality update and notify
/// character-sheet consumers. Retail routes 0x02CF through
/// <c>Handle_Qualities__PrivateUpdateInt64 @ 0x00559000</c>; Total XP (1)
/// and Available XP (2) are the character-window values carried here.
/// </summary>
public void OnInt64PropertyUpdate(uint propertyId, long value)
{
_properties.Int64s[propertyId] = value;
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;
}
/// <summary>
/// Return the character snapshot to its pre-login state. The object itself
/// is process-lived because UI view models subscribe to it once; session
/// replacement clears its contents instead of replacing the owner.
/// </summary>
/// <remarks>
/// Retail: <c>CPlayerSystem::OnEndCharacterSession @ 0x00562870</c>
/// calls <c>End @ 0x005606C0</c>, which invokes
/// <c>PlayerModule::Clear @ 0x005D48A0</c>, then immediately calls
/// <c>CPlayerSystem::Begin @ 0x0055D410</c>. Re-publishing invalidation
/// events is acdream's process-lived-view adaptation.
/// </remarks>
public void Clear()
{
_health = null;
_stamina = null;
_mana = null;
_attrs.Clear();
_skills.Clear();
_positions.Clear();
_properties = new PropertyBundle();
// Process-lived views pull through this object and use these events as
// their invalidation edge. Publish every category even when Clear is
// repeated so a failed reset attempt can safely converge on retry.
Changed?.Invoke(VitalKind.Health);
Changed?.Invoke(VitalKind.Stamina);
Changed?.Invoke(VitalKind.Mana);
foreach (AttributeKind kind in Enum.GetValues<AttributeKind>())
AttributeChanged?.Invoke(kind);
CharacterChanged?.Invoke();
}
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 +
// SecondaryAttributeTable (portal.dat 0x0E0..0x0E2). Coefficients are
// hardwired in retail and re-confirmed by holtburger's
// calculate_vital_attribute_contribution.
//
// MaxHealth formula = Endurance × 0.5
// MaxStamina formula = Endurance × 1.0
// MaxMana formula = Self × 1.0
//
// Unknown attribute → contribution 0 → max underestimated. Once the
// SecondaryAttributeTable port lands these can shift to dat-driven
// coefficients, but the values themselves don't change between dat
// versions in retail.
private uint AttributeContribution(VitalKind kind, bool effective)
{
uint endurance = GetAttrCurrent(AttributeKind.Endurance, effective);
uint self = GetAttrCurrent(AttributeKind.Self, effective);
switch (kind)
{
case VitalKind.Health:
// SecondaryAttributeTable's SkillFormula is
// floor((Endurance / 2) + 0.5), not integer truncation.
return (endurance / 2u) + (endurance & 1u);
case VitalKind.Stamina:
return endurance;
case VitalKind.Mana:
return self;
default:
return 0u;
}
}
private uint GetAttrCurrent(AttributeKind kind, bool effective)
{
if (!_attrs.TryGetValue(kind, out var attribute)) return 0u;
if (!effective || _spellbook is null) return attribute.Current;
int value = GetEffectiveAttribute(kind) ?? 0;
return value > 0 ? (uint)value : 0u;
}
private uint GearHealthBonus(VitalKind kind)
{
if (kind != VitalKind.Health) return 0u;
int value = _properties.GetInt((uint)PropertyInt.GearMaxHealth);
return value > 0 ? (uint)value : 0u;
}
}