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:
commit
2493f24c63
12 changed files with 984 additions and 108 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);
|
||||
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue