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

@ -3,6 +3,7 @@ using System.Linq;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Player;
using AcDream.Core.Spells;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
@ -208,4 +209,113 @@ public sealed class CharacterSheetProviderTests
Assert.Equal(400L, h.Player.Properties.GetInt64(2u)); // debited on the LPS side
Assert.True(changed >= 1); // and CharacterChanged fired
}
// ── Issue #267 — vitae/buff-aware skill + attribute values ───────────────
private static SpellMetadata TestSpell(uint spellId) => new(
spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0,
false, false, "", 0, 0, 0u, 0, false, false, true,
0f, 0u, 0u, 0u, 0);
private sealed class VitaeHarness
{
public ClientObjectTable Table { get; } = new();
public Spellbook Book { get; }
public LocalPlayerState Player { get; }
public CharacterSheetProvider Provider { get; }
public VitaeHarness()
{
Book = new Spellbook(SpellTable.Create([TestSpell(1u), TestSpell(2u)]));
Player = new LocalPlayerState(Book);
Provider = new CharacterSheetProvider(
Table, Player,
playerGuid: () => 0u,
activeToonName: () => "default",
fallbackSheet: name => new CharacterSheet { Name = name, Level = -1 });
}
}
[Fact]
public void BuildSheet_AttributeBuff_ShowsEffectiveValueAndBasePair()
{
var h = new VitaeHarness();
h.Player.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 2u, LayerId: 1u, Duration: 60d, CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 1u, StatModValue: 1.1f, Bucket: 1u));
var sheet = h.Provider.BuildSheet();
Assert.Equal(220, sheet.Strength); // effective: 200 * 1.1
Assert.Equal(200, sheet.AttributeBaseValues[0]); // base unaffected
}
[Fact]
public void BuildSheet_AttributeUnderVitae_AttributesAreVitaeImmune()
{
var h = new VitaeHarness();
h.Player.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u)); // 33% vitae
var sheet = h.Provider.BuildSheet();
Assert.Equal(200, sheet.Strength); // unaffected by vitae
Assert.Equal(200, sheet.AttributeBaseValues[0]);
}
[Fact]
public void BuildSheet_SkillUnderVitae_ShowsEffectiveLevelAndVitaeModifier()
{
var h = new VitaeHarness();
h.Player.OnSkillUpdate(skillId: 6u, ranks: 300u, status: 2u, xp: 0u,
init: 3u, resistance: 0u, lastUsed: 0d, formulaBonus: 0u); // base 303
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u)); // 33% vitae
var sheet = h.Provider.BuildSheet();
var skill = Assert.Single(sheet.Skills);
Assert.Equal(303, skill.BaseLevel);
Assert.Equal(203, skill.CurrentLevel); // 303 * 0.67, truncated
Assert.Equal(-100, skill.VitaeModifier); // the exact user-reported oracle example
}
[Fact]
public void SubscribeChanged_FiresOnEnchantmentsChanged_AndRebuildReflectsNewValue()
{
var h = new VitaeHarness();
h.Player.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
int changed = 0;
using IDisposable subscription = h.Provider.SubscribeChanged(() => changed++);
Assert.Equal(200, h.Provider.BuildSheet().Strength); // no buff yet
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 2u, LayerId: 1u, Duration: 60d, CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 1u, StatModValue: 1.1f, Bucket: 1u));
Assert.True(changed >= 1); // live-refresh notice fired
Assert.Equal(220, h.Provider.BuildSheet().Strength); // and the rebuilt sheet reflects it
}
[Fact]
public void SubscribeChanged_Dispose_UnsubscribesFromEnchantmentsChanged()
{
var h = new VitaeHarness();
int changed = 0;
IDisposable subscription = h.Provider.SubscribeChanged(() => changed++);
subscription.Dispose();
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u));
Assert.Equal(0, changed);
}
}

View file

@ -969,7 +969,11 @@ public class CharacterStatControllerTests
var rows = SkillRows(list);
rows[1].OnClick!();
Assert.Equal("War Magic: 285", title.LinesProvider()[0].Text);
// SampleData's War Magic row is intentionally Base=280/Current=285 (an
// illustrative buffed-skill sample — see SampleData.cs comment); issue
// #267 wires the retail "(+5)" buff-delta parenthetical onto that gap,
// which was previously computed but never surfaced in the title text.
Assert.Equal("War Magic: 285 (+5)", title.LinesProvider()[0].Text);
Assert.Equal("Experience To Raise:", l1Label.LinesProvider()[0].Text);
Assert.Equal((11_100_000L).ToString("N0"), l1Value.LinesProvider()[0].Text);
Assert.Equal("Unassigned Experience:", l2Label.LinesProvider()[0].Text);
@ -1270,6 +1274,178 @@ public class CharacterStatControllerTests
public void GetRowName_NegativeIndex_ReturnsEmpty()
=> Assert.Equal(string.Empty, CharacterStatController.GetRowName(-1));
// ── Issue #267 — vitae/buff delta parenthetical ──────────────────────────
// Retail format cited from gmAttributeUI::DisplaySelectionFooter_Attribute
// (0x0049d280, " (%s%d)") and gmSkillUI::DisplaySelectionFooter_Trained
// (0x0049b860, vitae segment " (%d)" + buff segment " (%s%d)").
[Fact]
public void GetAttributeDelta_ComputesEffectiveMinusBase()
{
var sheet = new CharacterSheet { Strength = 220, AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
Assert.Equal(20, CharacterStatController.GetAttributeDelta(sheet, 0));
}
[Fact]
public void GetAttributeDelta_VitalRowIndex_ReturnsZero()
{
// Vitals (rows 6-8) use a "cur/max" footer format in retail
// (DisplaySelectionFooter_Vital), not this delta pattern.
var sheet = new CharacterSheet { AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
Assert.Equal(0, CharacterStatController.GetAttributeDelta(sheet, 6));
}
[Fact]
public void GetAttributeDelta_EmptyBaseValues_ReturnsZero()
{
// SampleData / older sheets that don't populate AttributeBaseValues
// must not throw or fabricate a delta.
var sheet = new CharacterSheet { Strength = 220 };
Assert.Equal(0, CharacterStatController.GetAttributeDelta(sheet, 0));
}
[Fact]
public void GetSkillBuffOnlyDelta_IsolatesBuffFromVitae()
{
// CurrentLevel=251 already includes vitae (-99); the buff-only
// residual is (251 - (-99)) - 300 = 50.
var skill = new CharacterSkill(1u, "S", 0u, CharacterSkillAdvancementClass.Trained,
BaseLevel: 300, CurrentLevel: 251, UsableUntrained: true,
TrainedCost: 0, SpecializedCost: 0, RaiseCost: 0, VitaeModifier: -99);
Assert.Equal(50, CharacterStatController.GetSkillBuffOnlyDelta(skill));
}
[Fact]
public void RowClick_AttributeWithBuff_FooterTitleShowsPositiveDelta()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterSheet Sheet() => new() { Strength = 220, AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
CharacterStatController.Bind(layout, Sheet);
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // Strength = index 0
Assert.Equal("Strength: 220 (+20)", title.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_AttributeWithDebuff_FooterTitleShowsNegativeDelta()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterSheet Sheet() => new() { Endurance = 180, AttributeBaseValues = [0, 200, 0, 0, 0, 0] };
CharacterStatController.Bind(layout, Sheet);
list.Children.OfType<UiClickablePanel>().ToList()[1].OnClick!(); // Endurance = index 1
Assert.Equal("Endurance: 180 (-20)", title.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_AttributeZeroDelta_NoParenthetical()
{
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterSheet Sheet() => new() { Strength = 200, AttributeBaseValues = [200, 0, 0, 0, 0, 0] };
CharacterStatController.Bind(layout, Sheet);
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!();
Assert.Equal("Strength: 200", title.LinesProvider()[0].Text);
}
[Fact]
public void SkillClick_VitaeOnly_FooterTitleShowsVitaeParenthetical()
{
// User-reported oracle (ISSUES.md #267): a base-303 skill under 33%
// vitae shows the current (reduced) level with "(-100)".
var list = new UiPanel { Width = 300 };
var title = new UiText();
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.FooterTitleId, title));
CharacterSheet Sheet() => VitaeSkillSheet(currentLevel: 203, baseLevel: 303, vitaeModifier: -100);
CharacterStatController.Bind(layout, Sheet, spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
SkillRows(list)[0].OnClick!();
Assert.Equal("Test Skill: 203 (-100)", title.LinesProvider()[0].Text);
}
[Fact]
public void SkillClick_VitaePlusBuff_FooterTitleShowsBothParentheticals()
{
var list = new UiPanel { Width = 300 };
var title = new UiText();
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.FooterTitleId, title));
// CurrentLevel=251, base=300, vitae modifier=-99 (300*0.67-300).
// Buff-only delta = (251 - (-99)) - 300 = 50.
CharacterSheet Sheet() => VitaeSkillSheet(currentLevel: 251, baseLevel: 300, vitaeModifier: -99);
CharacterStatController.Bind(layout, Sheet, spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
SkillRows(list)[0].OnClick!();
Assert.Equal("Test Skill: 251 (-99) (+50)", title.LinesProvider()[0].Text);
}
[Fact]
public void SkillClick_ZeroDelta_NoParentheticals()
{
var list = new UiPanel { Width = 300 };
var title = new UiText();
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.FooterTitleId, title));
CharacterSheet Sheet() => VitaeSkillSheet(currentLevel: 300, baseLevel: 300, vitaeModifier: 0);
CharacterStatController.Bind(layout, Sheet, spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
SkillRows(list)[0].OnClick!();
Assert.Equal("Test Skill: 300", title.LinesProvider()[0].Text);
}
private static CharacterSheet VitaeSkillSheet(int currentLevel, int baseLevel, int vitaeModifier) => new()
{
SkillCredits = 0,
UnassignedXp = 1_000_000,
Skills =
[
new CharacterSkill(
200u,
"Test Skill",
0x06000001u,
CharacterSkillAdvancementClass.Trained,
BaseLevel: baseLevel,
CurrentLevel: currentLevel,
UsableUntrained: true,
TrainedCost: 0,
SpecializedCost: 0,
RaiseCost: 100,
Raise10Cost: 1000,
VitaeModifier: vitaeModifier),
],
};
// ── SampleData sanity ─────────────────────────────────────────────────────
[Fact]