fix(ui): #267 character panel reflects vitae/buffed skills and attributes

Retail CACQualities::EnchantAttribute (0x00594570), EnchantAttribute2nd
(0x00594670, already ported for #6), and EnchantSkill (0x005947b0) are the
three enchantment-composition functions the Character window's Attributes
and Skills tabs depend on. Primary attributes never reference the vitae
singleton in retail (only Attribute2nd/Skill do) — confirmed directly from
the decompiled function bodies, not assumed.

EnchantmentMath.GetMod gains requiredType/includeVitae parameters (default
to the prior behavior) so a numeric StatMod key collision across domains
(e.g. key=1 is both Strength and MaxHealth) can't leak a buff into the
wrong computation. Spellbook.GetAttributeMod/GetSkillMod and
LocalPlayerState.GetEffectiveAttribute/GetEffectiveSkill/
GetSkillVitaeModifier wire the retail chain through to the panel.
CharacterSheetProvider now reports the effective value as the main number
and CharacterSkill.CurrentLevel is no longer an alias of BaseLevel (this
also activates the previously-dead SkillValueColor buffed/debuffed row
coloring). CharacterStatController's footer-title parenthetical is cited
from gmAttributeUI::DisplaySelectionFooter_Attribute (0x0049d280) and
gmSkillUI::DisplaySelectionFooter_Trained (0x0049b860) +
SkillInfoRegion::GetVitaeModifier (0x004f0fa0): skills show up to two
segments (vitae's own contribution, then the buff-only residual), while
vitae-immune attributes show at most one; no parenthetical when the delta
is zero. The panel now refreshes on Spellbook.EnchantmentsChanged, not only
raw property/attribute updates.

Core goldens cover the user-reported 33% vitae example (303->203, "(-100)"
exactly), buff+vitae composition, and the attribute vitae-immunity finding.
Provider/controller tests cover the full row-click -> footer-title path and
live refresh. Full solution suite passes with zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 18:08:55 +02:00
parent f6275f4501
commit cf2605fa4a
11 changed files with 1089 additions and 53 deletions

View file

@ -141,12 +141,16 @@ public sealed class CharacterSheetProvider
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),
// Issue #267: the panel's main attribute values are EFFECTIVE
// (post-buff) — retail CACQualities::EnchantAttribute. Base values
// for the footer-title delta parenthetical live in
// AttributeBaseValues below, in the same positional order.
Strength = AttrEffective(LocalPlayerState.AttributeKind.Strength),
Endurance = AttrEffective(LocalPlayerState.AttributeKind.Endurance),
Coordination = AttrEffective(LocalPlayerState.AttributeKind.Coordination),
Quickness = AttrEffective(LocalPlayerState.AttributeKind.Quickness),
Focus = AttrEffective(LocalPlayerState.AttributeKind.Focus),
Self = AttrEffective(LocalPlayerState.AttributeKind.Self),
UnspentSkillCredits = skillCredits,
SpecializedSkillCredits = 0,
@ -163,6 +167,15 @@ public sealed class CharacterSheetProvider
UnassignedXp = unassignedXp,
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10),
AttributeBaseValues = new[]
{
AttrCurrent(LocalPlayerState.AttributeKind.Strength),
AttrCurrent(LocalPlayerState.AttributeKind.Endurance),
AttrCurrent(LocalPlayerState.AttributeKind.Coordination),
AttrCurrent(LocalPlayerState.AttributeKind.Quickness),
AttrCurrent(LocalPlayerState.AttributeKind.Focus),
AttrCurrent(LocalPlayerState.AttributeKind.Self),
},
Skills = BuildLiveCharacterSkills(),
BurdenCurrent = props.GetInt(5u),
BurdenMax = props.GetInt(96u),
@ -207,6 +220,12 @@ public sealed class CharacterSheetProvider
owner._objects.Cleared += OnCleared;
owner._localPlayer.AttributeChanged += OnAttributeChanged;
owner._localPlayer.CharacterChanged += OnCharacterChanged;
// Issue #267: skills/attributes are now vitae + buff aware, so the
// sheet must refresh whenever the active-enchantment set changes
// (vitae applied/removed on death/lifestone, a buff cast/expired),
// not only on raw property/attribute updates.
if (owner._localPlayer.Spellbook is { } spellbook)
spellbook.EnchantmentsChanged += OnCleared;
}
private void OnObjectChanged(ClientObject value)
@ -235,6 +254,8 @@ public sealed class CharacterSheetProvider
owner._objects.Cleared -= OnCleared;
owner._localPlayer.AttributeChanged -= OnAttributeChanged;
owner._localPlayer.CharacterChanged -= OnCharacterChanged;
if (owner._localPlayer.Spellbook is { } spellbook)
spellbook.EnchantmentsChanged -= OnCleared;
}
}
@ -349,18 +370,27 @@ public sealed class CharacterSheetProvider
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
// Issue #267: CurrentLevel is the EFFECTIVE (vitae + buff) level —
// retail CACQualities::EnchantSkill (0x005947b0). VitaeModifier
// isolates vitae's own contribution for the footer's separate
// vitae parenthetical (SkillInfoRegion::GetVitaeModifier 0x004f0fa0).
int effectiveLevel = _localPlayer.GetEffectiveSkill(snapshot.SkillId)
?? checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel));
int vitaeModifier = _localPlayer.GetSkillVitaeModifier(snapshot.SkillId);
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)),
effectiveLevel,
IsUsableUntrained(snapshot.SkillId),
trainedCost,
specializedCost,
raiseCost,
raise10Cost));
raise10Cost,
vitaeModifier));
}
return result;
@ -418,9 +448,19 @@ public sealed class CharacterSheetProvider
_ => null,
};
/// <summary>Unenchanted base attribute value (Ranks + Start). Used for
/// <see cref="CharacterSheet.AttributeBaseValues"/> — the retail
/// footer-title delta parenthetical compares this against
/// <see cref="AttrEffective"/>.</summary>
private int AttrCurrent(LocalPlayerState.AttributeKind kind) =>
_localPlayer.GetAttribute(kind) is { } attr ? checked((int)Math.Min(int.MaxValue, attr.Current)) : 0;
/// <summary>Issue #267 — effective (post-buff) attribute value shown as
/// the panel's main number. Retail primary attributes are vitae-immune;
/// see <see cref="LocalPlayerState.GetEffectiveAttribute"/>.</summary>
private int AttrEffective(LocalPlayerState.AttributeKind kind) =>
_localPlayer.GetEffectiveAttribute(kind) ?? 0;
private int VitalCurrent(LocalPlayerState.VitalKind kind) =>
_localPlayer.Get(kind) is { } vital ? checked((int)Math.Min(int.MaxValue, vital.Current)) : 0;