merge: #267 vitae character-panel display (attributes vitae-immune per retail; skill dual parentheticals)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 18:14:48 +02:00
commit 2493f24c63
12 changed files with 984 additions and 108 deletions

View file

@ -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);
}

View file

@ -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);
@ -235,7 +379,7 @@ public sealed class EnchantmentMathTests
uint spellId, uint layer, uint skillId, float val) =>
new(
spellId, layer, 60f, 0u,
StatModType: EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModKey: skillId,
StatModValue: val,
Bucket: 1u);
@ -244,7 +388,7 @@ public sealed class EnchantmentMathTests
uint spellId, uint layer, uint skillId, float val) =>
new(
spellId, layer, 60f, 0u,
StatModType: EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModKey: skillId,
StatModValue: val,
Bucket: 2u);
@ -253,8 +397,8 @@ public sealed class EnchantmentMathTests
public void EnchantmentTypeFlag_Skill_MatchesAceEnchantmentTypeFlags()
{
// ACE.Entity.Enum.EnchantmentTypeFlags.Skill = 0x0000010.
Assert.Equal(0x0000010u, EnchantmentMath.EnchantmentTypeFlag.Skill);
Assert.Equal(0x0000002u, EnchantmentMath.EnchantmentTypeFlag.SecondAtt);
Assert.Equal(0x0000010u, (uint)EnchantmentMath.EnchantmentTypeFlag.Skill);
Assert.Equal(0x0000002u, (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt);
}
[Fact]
@ -304,7 +448,7 @@ public sealed class EnchantmentMathTests
{
new ActiveEnchantmentRecord(
53u, 1u, 60f, 0u,
StatModType: EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModKey: 24u,
StatModValue: 1.5f,
Bucket: 1u),