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:
parent
f6275f4501
commit
cf2605fa4a
11 changed files with 1089 additions and 53 deletions
|
|
@ -1,5 +1,6 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Core.Tests.Player;
|
||||
|
||||
|
|
@ -477,4 +478,117 @@ public sealed class LocalPlayerStateTests
|
|||
Assert.Empty(s.Skills);
|
||||
Assert.Empty(s.Properties.Ints);
|
||||
}
|
||||
|
||||
// ── Issue #267 — effective attribute/skill values (vitae + buff aware) ──
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveAttribute_NoSpellbook_ReturnsBaseValue()
|
||||
{
|
||||
var s = new LocalPlayerState(); // no spellbook wired — back-compat
|
||||
s.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
|
||||
|
||||
Assert.Equal(200, s.GetEffectiveAttribute(LocalPlayerState.AttributeKind.Strength));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveAttribute_Unseen_ReturnsNull()
|
||||
{
|
||||
var s = new LocalPlayerState(new Spellbook());
|
||||
Assert.Null(s.GetEffectiveAttribute(LocalPlayerState.AttributeKind.Strength));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveAttribute_VitaeActive_AttributesAreVitaeImmune()
|
||||
{
|
||||
// Retail CACQualities::EnchantAttribute (0x00594570) never references
|
||||
// the vitae singleton — a 33% vitae penalty must NOT move Strength.
|
||||
var book = new Spellbook(SpellTable.Create([TestSpell(1u), TestSpell(2u)]));
|
||||
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
|
||||
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u));
|
||||
var s = new LocalPlayerState(book);
|
||||
s.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
|
||||
|
||||
Assert.Equal(200, s.GetEffectiveAttribute(LocalPlayerState.AttributeKind.Strength));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveAttribute_Buff_AppliesMultiplierAndTruncates()
|
||||
{
|
||||
var book = new Spellbook(SpellTable.Create([TestSpell(1u), TestSpell(2u)]));
|
||||
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
SpellId: 2u, LayerId: 1u, Duration: 60d, CasterGuid: 0u,
|
||||
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
|
||||
StatModKey: 1u /* Strength */, StatModValue: 1.1f, Bucket: 1u));
|
||||
var s = new LocalPlayerState(book);
|
||||
s.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
|
||||
|
||||
// 200 * 1.1 = 220.
|
||||
Assert.Equal(220, s.GetEffectiveAttribute(LocalPlayerState.AttributeKind.Strength));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveSkill_NoSpellbook_ReturnsBaseValue()
|
||||
{
|
||||
var s = new LocalPlayerState();
|
||||
s.OnSkillUpdate(skillId: 6u, ranks: 300u, status: 2u, xp: 0u,
|
||||
init: 3u, resistance: 0u, lastUsed: 0d, formulaBonus: 0u); // base 303
|
||||
|
||||
Assert.Equal(303, s.GetEffectiveSkill(6u));
|
||||
Assert.Equal(0, s.GetSkillVitaeModifier(6u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveSkill_Unseen_ReturnsNull()
|
||||
{
|
||||
var s = new LocalPlayerState(new Spellbook());
|
||||
Assert.Null(s.GetEffectiveSkill(6u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveSkill_ThirtyThreePercentVitae_MatchesUserReportedGolden()
|
||||
{
|
||||
// User-reported oracle (ISSUES.md #267): a base-303 skill under 33%
|
||||
// vitae shows the current (reduced) level with "(-100)" in parens.
|
||||
var book = new Spellbook(SpellTable.Create([TestSpell(1u), TestSpell(2u)]));
|
||||
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
|
||||
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u));
|
||||
var s = new LocalPlayerState(book);
|
||||
s.OnSkillUpdate(skillId: 6u, ranks: 300u, status: 2u, xp: 0u,
|
||||
init: 3u, resistance: 0u, lastUsed: 0d, formulaBonus: 0u); // base 303
|
||||
|
||||
Assert.Equal(203, s.GetEffectiveSkill(6u));
|
||||
Assert.Equal(-100, s.GetSkillVitaeModifier(6u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveSkill_BuffPlusVitaeComposition()
|
||||
{
|
||||
var book = new Spellbook(SpellTable.Create([TestSpell(1u), TestSpell(2u)]));
|
||||
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
|
||||
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u)); // 33% vitae
|
||||
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
SpellId: 2u, LayerId: 2u, Duration: 60d, CasterGuid: 0u,
|
||||
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Skill,
|
||||
StatModKey: 6u, StatModValue: 50f, Bucket: 2u)); // +50 additive buff
|
||||
var s = new LocalPlayerState(book);
|
||||
s.OnSkillUpdate(skillId: 6u, ranks: 297u, status: 2u, xp: 0u,
|
||||
init: 3u, resistance: 0u, lastUsed: 0d, formulaBonus: 0u); // base 300
|
||||
|
||||
// (300 * 0.67) + 50 = 201 + 50 = 251. Vitae-only contribution stays
|
||||
// isolated at (300*0.67) - 300 = -99.
|
||||
Assert.Equal(251, s.GetEffectiveSkill(6u));
|
||||
Assert.Equal(-99, s.GetSkillVitaeModifier(6u));
|
||||
}
|
||||
|
||||
/// <summary>Minimal SpellTable row — <see cref="EnchantmentMath.GetMod"/>'s
|
||||
/// family-stacking pass skips any enchantment whose SpellId isn't in the
|
||||
/// table, so vitae/buff test records need an entry here even with
|
||||
/// Family=0 (no dedup bucket).</summary>
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,6 +219,150 @@ public sealed class EnchantmentMathTests
|
|||
Assert.Equal(1.5f, mod.Multiplier, precision: 3);
|
||||
}
|
||||
|
||||
// ── Issue #267 — Attribute/Skill domain filter + EnchantAttribute/EnchantSkill goldens ──
|
||||
|
||||
[Fact]
|
||||
public void GetMod_RequiredType_ExcludesCrossDomainKeyCollision()
|
||||
{
|
||||
// A Strength buff (Attribute, key=1) must NOT leak into a MaxHealth
|
||||
// (SecondAtt, key=1) computation just because the numeric key collides.
|
||||
var table = LoadTable((50u, "Strength Buff", 0u));
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeTypedMultRecord(spellId: 50, layer: 1, statKey: 1u,
|
||||
statModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute, val: 1.5f),
|
||||
};
|
||||
|
||||
var secondAttMod = EnchantmentMath.GetMod(enchantments, table, statKey: 1u,
|
||||
EnchantmentMath.EnchantmentTypeFlag.SecondAtt);
|
||||
Assert.Equal(EnchantmentMath.VitalMod.Identity, secondAttMod);
|
||||
|
||||
var attributeMod = EnchantmentMath.GetMod(enchantments, table, statKey: 1u,
|
||||
EnchantmentMath.EnchantmentTypeFlag.Attribute);
|
||||
Assert.Equal(1.5f, attributeMod.Multiplier, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMod_IncludeVitaeFalse_ExcludesVitaeEvenWhenActive()
|
||||
{
|
||||
// Primary attributes (EnchantAttribute) never reference the vitae
|
||||
// singleton in retail — includeVitae:false must fully exclude it.
|
||||
var table = LoadTable((60u, "Vitae", 0u));
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeVitaeRecord(spellId: 60, layer: 1, statKey: 0u, val: 0.67f),
|
||||
};
|
||||
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table, statKey: 1u,
|
||||
EnchantmentMath.EnchantmentTypeFlag.Attribute, includeVitae: false);
|
||||
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnchantAttribute_NoMods_ReturnsBaseTruncated()
|
||||
{
|
||||
Assert.Equal(200, EnchantmentMath.EnchantAttribute(EnchantmentMath.VitalMod.Identity, 200u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnchantAttribute_Buff_AppliesMultiplierAndTruncates()
|
||||
{
|
||||
// 200 base with a +10% buff (unrelated to vitae — attributes are
|
||||
// vitae-immune) → 220.
|
||||
var mod = new EnchantmentMath.VitalMod(1.1f, 0f);
|
||||
Assert.Equal(220, EnchantmentMath.EnchantAttribute(mod, 200u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnchantAttribute_FloorsAtOne_WhenBaseBelowTenAndDebuffed()
|
||||
{
|
||||
// Base 5 (< 10) crushed by a 0.1 multiplier would compute to 0.5,
|
||||
// but retail floors small attributes at 1 rather than letting them
|
||||
// hit zero (0x00594570: `< 0xa` branch floors at 1f).
|
||||
var mod = new EnchantmentMath.VitalMod(0.1f, 0f);
|
||||
Assert.Equal(1, EnchantmentMath.EnchantAttribute(mod, 5u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnchantAttribute_FloorsAtTen_WhenBaseAtOrAboveTenAndDebuffed()
|
||||
{
|
||||
// Base 50 (>= 10) crushed by a 0.1 multiplier would compute to 5,
|
||||
// but retail floors at 10 for this base range.
|
||||
var mod = new EnchantmentMath.VitalMod(0.1f, 0f);
|
||||
Assert.Equal(10, EnchantmentMath.EnchantAttribute(mod, 50u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnchantSkill_ThirtyThreePercentVitae_MatchesGoldenValue()
|
||||
{
|
||||
// 33% vitae penalty on a base-303 skill: 303 * 0.67 = 203.01 -> 203.
|
||||
var mod = new EnchantmentMath.VitalMod(0.67f, 0f);
|
||||
Assert.Equal(203, EnchantmentMath.EnchantSkill(mod, 303u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnchantSkill_BuffPlusVitaeComposition_MatchesGoldenValue()
|
||||
{
|
||||
// Base 300, vitae 0.67 (33%) composed with a +50 additive buff:
|
||||
// (300 * 0.67) + 50 = 201 + 50 = 251.
|
||||
var mod = new EnchantmentMath.VitalMod(0.67f, 50f);
|
||||
Assert.Equal(251, EnchantmentMath.EnchantSkill(mod, 300u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnchantSkill_ZeroFloorsBelowHalf()
|
||||
{
|
||||
// A crushing vitae/debuff combination that drops the result under
|
||||
// 0.5 floors to 0 rather than truncating to a stray small value.
|
||||
var mod = new EnchantmentMath.VitalMod(0.001f, 0f);
|
||||
Assert.Equal(0, EnchantmentMath.EnchantSkill(mod, 10u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVitaeMultiplier_NoVitae_ReturnsOne()
|
||||
{
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeMultRecord(spellId: 1, layer: 1, statKey: 1u, val: 1.5f),
|
||||
};
|
||||
Assert.Equal(1.0f, EnchantmentMath.GetVitaeMultiplier(enchantments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVitaeMultiplier_WithVitae_ReturnsItsValue()
|
||||
{
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeVitaeRecord(spellId: 1, layer: 1, statKey: 0u, val: 0.67f),
|
||||
MakeMultRecord(spellId: 2, layer: 2, statKey: 1u, val: 1.5f), // must not affect vitae isolation
|
||||
};
|
||||
Assert.Equal(0.67f, EnchantmentMath.GetVitaeMultiplier(enchantments), precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillVitaeModifier_ThirtyThreePercent_MatchesGoldenValue()
|
||||
{
|
||||
// Base 303 skill, 33% vitae: truncate(303 * 0.67) - 303 = 203 - 303 = -100.
|
||||
// This is the user-reported oracle example: "(-100)".
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeVitaeRecord(spellId: 1, layer: 1, statKey: 0u, val: 0.67f),
|
||||
};
|
||||
Assert.Equal(-100, EnchantmentMath.SkillVitaeModifier(enchantments, baseValue: 303u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillVitaeModifier_NoVitae_ReturnsZero()
|
||||
{
|
||||
Assert.Equal(0, EnchantmentMath.SkillVitaeModifier(
|
||||
System.Array.Empty<ActiveEnchantmentRecord>(), baseValue: 303u));
|
||||
}
|
||||
|
||||
private static ActiveEnchantmentRecord MakeTypedMultRecord(
|
||||
uint spellId, uint layer, uint statKey, uint statModType, float val) =>
|
||||
new(spellId, layer, 60f, 0u, StatModType: statModType, StatModKey: statKey,
|
||||
StatModValue: val, Bucket: 1u);
|
||||
|
||||
private static ActiveEnchantmentRecord MakeMultRecord(uint spellId, uint layer, uint statKey, float val) =>
|
||||
new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 1u);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue