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>
594 lines
22 KiB
C#
594 lines
22 KiB
C#
using AcDream.Core.Items;
|
|
using AcDream.Core.Player;
|
|
using AcDream.Core.Spells;
|
|
|
|
namespace AcDream.Core.Tests.Player;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="LocalPlayerState"/> — per-vital + per-attribute
|
|
/// cache populated from <c>PlayerDescription</c>'s attribute block (ids
|
|
/// 1..=6 primary attrs, 7..=9 vitals) and from
|
|
/// <c>PrivateUpdateVital(Current)</c> deltas (ids 1..=6 in their
|
|
/// alternate role as wire-opcode <c>Vital</c> ids).
|
|
///
|
|
/// <para>
|
|
/// Max formula tested here: <c>vital.(ranks+start) + attribute_contribution</c>
|
|
/// with retail coefficients (Endurance/2 for Health, Endurance for Stamina,
|
|
/// Self for Mana). See class doc.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class LocalPlayerStateTests
|
|
{
|
|
[Fact]
|
|
public void Defaults_AllVitalsNull_PercentsNull()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
|
|
Assert.Null(s.Get(LocalPlayerState.VitalKind.Health));
|
|
Assert.Null(s.Get(LocalPlayerState.VitalKind.Stamina));
|
|
Assert.Null(s.Get(LocalPlayerState.VitalKind.Mana));
|
|
Assert.Null(s.HealthPercent);
|
|
Assert.Null(s.StaminaPercent);
|
|
Assert.Null(s.ManaPercent);
|
|
}
|
|
|
|
[Theory]
|
|
// Wire opcode IDs (PrivateUpdateVital + PrivateUpdateVitalCurrent),
|
|
// ACE Vital enum: Undef=0, MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6.
|
|
[InlineData(1u, LocalPlayerState.VitalKind.Health)]
|
|
[InlineData(2u, LocalPlayerState.VitalKind.Health)]
|
|
[InlineData(3u, LocalPlayerState.VitalKind.Stamina)]
|
|
[InlineData(4u, LocalPlayerState.VitalKind.Stamina)]
|
|
[InlineData(5u, LocalPlayerState.VitalKind.Mana)]
|
|
[InlineData(6u, LocalPlayerState.VitalKind.Mana)]
|
|
// PlayerDescription attribute-block IDs.
|
|
[InlineData(7u, LocalPlayerState.VitalKind.Health)]
|
|
[InlineData(8u, LocalPlayerState.VitalKind.Stamina)]
|
|
[InlineData(9u, LocalPlayerState.VitalKind.Mana)]
|
|
public void VitalIdToKind_MapsBothIdSystems_ToSameKind(uint vitalId, LocalPlayerState.VitalKind expected)
|
|
{
|
|
Assert.Equal(expected, LocalPlayerState.VitalIdToKind(vitalId));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0u)]
|
|
[InlineData(10u)]
|
|
[InlineData(99u)]
|
|
public void VitalIdToKind_ReturnsNull_ForUnknownId(uint vitalId)
|
|
{
|
|
Assert.Null(LocalPlayerState.VitalIdToKind(vitalId));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1u, LocalPlayerState.AttributeKind.Strength)]
|
|
[InlineData(2u, LocalPlayerState.AttributeKind.Endurance)]
|
|
[InlineData(3u, LocalPlayerState.AttributeKind.Quickness)]
|
|
[InlineData(4u, LocalPlayerState.AttributeKind.Coordination)]
|
|
[InlineData(5u, LocalPlayerState.AttributeKind.Focus)]
|
|
[InlineData(6u, LocalPlayerState.AttributeKind.Self)]
|
|
public void AttributeIdToKind_MapsPrimaryAttrIds(uint atType, LocalPlayerState.AttributeKind expected)
|
|
{
|
|
Assert.Equal(expected, LocalPlayerState.AttributeIdToKind(atType));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0u)]
|
|
[InlineData(7u)] // Vitals are not primary attrs in this lookup.
|
|
[InlineData(99u)]
|
|
public void AttributeIdToKind_ReturnsNull_ForNonPrimaryIds(uint atType)
|
|
{
|
|
Assert.Null(LocalPlayerState.AttributeIdToKind(atType));
|
|
}
|
|
|
|
[Fact]
|
|
public void OnVitalUpdate_PopulatesSnapshot_FromFullMessage()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnVitalUpdate(vitalId: 4u, ranks: 100u, start: 120u, xp: 50000u, current: 180u);
|
|
|
|
var stam = s.Get(LocalPlayerState.VitalKind.Stamina);
|
|
Assert.NotNull(stam);
|
|
Assert.Equal(100u, stam!.Value.Ranks);
|
|
Assert.Equal(120u, stam.Value.Start);
|
|
Assert.Equal(50000u, stam.Value.Xp);
|
|
Assert.Equal(180u, stam.Value.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnAttributeUpdate_PopulatesSnapshot()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 100u, xp: 12345u);
|
|
|
|
var endurance = s.GetAttribute(LocalPlayerState.AttributeKind.Endurance);
|
|
Assert.NotNull(endurance);
|
|
Assert.Equal(50u, endurance!.Value.Ranks);
|
|
Assert.Equal(100u, endurance.Value.Start);
|
|
Assert.Equal(12345u, endurance.Value.Xp);
|
|
// Current = ranks + start.
|
|
Assert.Equal(150u, endurance.Value.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void HealthPercent_UsesEnduranceContribution_DividedByTwo()
|
|
{
|
|
// Endurance.current = ranks(50) + start(150) = 200. Contribution = 100.
|
|
// Health vital: ranks=0 start=0 cur=80. MaxApprox = 0 + 100 = 100. Percent = 0.8.
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u); // Endurance
|
|
s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 0u, xp: 0u, current: 80u); // Health (PD id)
|
|
|
|
Assert.Equal(100u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
|
|
Assert.Equal(0.8f, s.HealthPercent!.Value, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void StaminaPercent_UsesEnduranceContribution_FullValue()
|
|
{
|
|
// Endurance.current = 200, Health/2=100. Stamina takes full Endurance=200.
|
|
// Stamina vital: ranks=0 start=0 cur=150. MaxApprox = 0 + 200 = 200. Percent = 0.75.
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u);
|
|
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 150u);
|
|
|
|
Assert.Equal(200u, s.GetMaxApprox(LocalPlayerState.VitalKind.Stamina));
|
|
Assert.Equal(0.75f, s.StaminaPercent!.Value, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void ManaPercent_UsesSelfContribution()
|
|
{
|
|
// Self.current = 100. Mana vital: ranks=20 start=80 cur=100. MaxApprox = 100 + 100 = 200.
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 6u, ranks: 50u, start: 50u, xp: 0u); // Self
|
|
s.OnVitalUpdate(vitalId: 9u, ranks: 20u, start: 80u, xp: 0u, current: 100u); // Mana
|
|
|
|
Assert.Equal(200u, s.GetMaxApprox(LocalPlayerState.VitalKind.Mana));
|
|
Assert.Equal(0.5f, s.ManaPercent!.Value, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void Percent_ZeroWhenAttributeAndVitalBothZero()
|
|
{
|
|
// Without any attribute or vital ranks, MaxApprox=0 → percent null
|
|
// (no /0). Vital received but no useful information.
|
|
var s = new LocalPlayerState();
|
|
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 0u);
|
|
|
|
Assert.Null(s.StaminaPercent);
|
|
}
|
|
|
|
[Fact]
|
|
public void Percent_ClampsToOne_WhenCurrentExceedsMax()
|
|
{
|
|
// Endurance contribution = 100/1 = 100. Stamina ranks+start = 0.
|
|
// MaxApprox = 100. Current = 150 → ratio 1.5 → clamps to 1.0.
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 50u, xp: 0u);
|
|
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 150u);
|
|
|
|
Assert.Equal(1f, s.StaminaPercent!.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnVitalCurrent_UpdatesOnlyCurrent_LeavesRanksStartXpAlone()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u);
|
|
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 50000u, current: 180u);
|
|
s.OnVitalCurrent(vitalId: 8u, current: 90u);
|
|
|
|
var stam = s.Get(LocalPlayerState.VitalKind.Stamina)!.Value;
|
|
Assert.Equal(0u, stam.Ranks);
|
|
Assert.Equal(0u, stam.Start);
|
|
Assert.Equal(50000u, stam.Xp);
|
|
Assert.Equal(90u, stam.Current);
|
|
// Endurance.current = 200; MaxApprox = 200; percent = 90/200 = 0.45.
|
|
Assert.Equal(0.45f, s.StaminaPercent!.Value, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnVitalCurrent_NoOp_WhenNoFullUpdateYet()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnVitalCurrent(vitalId: 4u, current: 90u);
|
|
|
|
Assert.Null(s.Get(LocalPlayerState.VitalKind.Stamina));
|
|
Assert.Null(s.StaminaPercent);
|
|
}
|
|
|
|
[Fact]
|
|
public void Changed_FiresOnFullVitalUpdate_WithCorrectKind()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var seen = new List<LocalPlayerState.VitalKind>();
|
|
s.Changed += k => seen.Add(k);
|
|
|
|
s.OnVitalUpdate(vitalId: 2u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
|
|
s.OnVitalUpdate(vitalId: 4u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
|
|
s.OnVitalUpdate(vitalId: 6u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
|
|
|
|
Assert.Equal(new[]
|
|
{
|
|
LocalPlayerState.VitalKind.Health,
|
|
LocalPlayerState.VitalKind.Stamina,
|
|
LocalPlayerState.VitalKind.Mana,
|
|
}, seen);
|
|
}
|
|
|
|
[Fact]
|
|
public void AttributeChanged_FiresOnPrimaryAttrUpdate()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var seen = new List<LocalPlayerState.AttributeKind>();
|
|
s.AttributeChanged += k => seen.Add(k);
|
|
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 1u, start: 1u, xp: 0u); // Endurance
|
|
s.OnAttributeUpdate(atType: 6u, ranks: 1u, start: 1u, xp: 0u); // Self
|
|
|
|
Assert.Equal(new[]
|
|
{
|
|
LocalPlayerState.AttributeKind.Endurance,
|
|
LocalPlayerState.AttributeKind.Self,
|
|
}, seen);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnAttributeUpdate_DoesNotAffectVitals_DirectlyButRefreshesPercent()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 100u);
|
|
|
|
// Pre-attribute: percent null because MaxApprox = 0.
|
|
Assert.Null(s.StaminaPercent);
|
|
|
|
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u); // Endurance.current = 200
|
|
// Now MaxApprox = 0 + 200 = 200; percent = 100/200 = 0.5.
|
|
Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnProperties_ClonesBundleAndExposesInt64()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var props = new PropertyBundle();
|
|
props.Ints[0x19u] = 126;
|
|
props.Int64s[1u] = 1_234_567_890L;
|
|
|
|
s.OnProperties(props);
|
|
props.Ints[0x19u] = 1;
|
|
props.Int64s[1u] = 2L;
|
|
|
|
Assert.Equal(126, s.Properties.GetInt(0x19u));
|
|
Assert.Equal(1_234_567_890L, s.Properties.GetInt64(1u));
|
|
}
|
|
|
|
[Fact]
|
|
public void OnInt64PropertyUpdate_ReplacesValueAndFiresCharacterChanged()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var props = new PropertyBundle();
|
|
props.Int64s[1u] = 100L;
|
|
s.OnProperties(props);
|
|
int changed = 0;
|
|
s.CharacterChanged += () => changed++;
|
|
|
|
s.OnInt64PropertyUpdate(1u, 1_234_567_890L);
|
|
|
|
Assert.Equal(1_234_567_890L, s.Properties.GetInt64(1u));
|
|
Assert.Equal(1, changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnSkillUpdate_StoresFormulaAdjustedCurrent()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
int changed = 0;
|
|
s.CharacterChanged += () => changed++;
|
|
|
|
s.OnSkillUpdate(
|
|
skillId: 24u,
|
|
ranks: 12u,
|
|
status: 2u,
|
|
xp: 3456u,
|
|
init: 30u,
|
|
resistance: 0u,
|
|
lastUsed: 1.5,
|
|
formulaBonus: 80u);
|
|
|
|
var run = s.GetSkill(24u);
|
|
Assert.NotNull(run);
|
|
Assert.Equal(122u, run!.Value.CurrentLevel);
|
|
Assert.Equal(2u, run.Value.Status);
|
|
Assert.Equal(3456u, run.Value.Xp);
|
|
Assert.Equal(1, changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplySkillTraining_PromotesUntrainedSkillAndFiresCharacterChanged()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
int changed = 0;
|
|
s.CharacterChanged += () => changed++;
|
|
s.OnSkillUpdate(
|
|
skillId: 21u,
|
|
ranks: 0u,
|
|
status: 1u,
|
|
xp: 0u,
|
|
init: 5u,
|
|
resistance: 0u,
|
|
lastUsed: 0,
|
|
formulaBonus: 10u);
|
|
changed = 0;
|
|
|
|
Assert.True(s.ApplySkillTraining(21u));
|
|
|
|
var skill = s.GetSkill(21u);
|
|
Assert.NotNull(skill);
|
|
Assert.Equal(2u, skill!.Value.Status);
|
|
Assert.Equal(1, changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplySkillRaise_AddsRanksAndSpentXp()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnSkillUpdate(
|
|
skillId: 24u,
|
|
ranks: 12u,
|
|
status: 2u,
|
|
xp: 3456u,
|
|
init: 30u,
|
|
resistance: 0u,
|
|
lastUsed: 0,
|
|
formulaBonus: 80u);
|
|
|
|
Assert.True(s.ApplySkillRaise(24u, amount: 10u, xpSpent: 500u));
|
|
|
|
var run = s.GetSkill(24u);
|
|
Assert.NotNull(run);
|
|
Assert.Equal(22u, run!.Value.Ranks);
|
|
Assert.Equal(3956u, run.Value.Xp);
|
|
Assert.Equal(132u, run.Value.CurrentLevel);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyAttributeRaise_AddsRanksAndSpentXp()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnAttributeUpdate(atType: 5u, ranks: 10u, start: 10u, xp: 100u);
|
|
|
|
Assert.True(s.ApplyAttributeRaise(atType: 5u, amount: 1u, xpSpent: 25u));
|
|
|
|
var focus = s.GetAttribute(LocalPlayerState.AttributeKind.Focus);
|
|
Assert.NotNull(focus);
|
|
Assert.Equal(11u, focus!.Value.Ranks);
|
|
Assert.Equal(125u, focus.Value.Xp);
|
|
Assert.Equal(21u, focus.Value.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyVitalRaise_AddsRanksAndSpentXpWithoutChangingCurrent()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
s.OnVitalUpdate(vitalId: 7u, ranks: 5u, start: 10u, xp: 100u, current: 12u);
|
|
|
|
Assert.True(s.ApplyVitalRaise(vitalId: 1u, amount: 1u, xpSpent: 25u));
|
|
|
|
var health = s.Get(LocalPlayerState.VitalKind.Health);
|
|
Assert.NotNull(health);
|
|
Assert.Equal(6u, health!.Value.Ranks);
|
|
Assert.Equal(125u, health.Value.Xp);
|
|
Assert.Equal(12u, health.Value.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void DebitIntProperty_Present_DebitsClampsAndFiresCharacterChanged()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var props = new PropertyBundle();
|
|
props.Ints[0x18u] = 3;
|
|
s.OnProperties(props);
|
|
int changed = 0;
|
|
s.CharacterChanged += () => changed++;
|
|
|
|
Assert.True(s.DebitIntProperty(0x18u, 2));
|
|
Assert.Equal(1, s.Properties.GetInt(0x18u));
|
|
|
|
Assert.True(s.DebitIntProperty(0x18u, 5)); // over-debit clamps at 0
|
|
Assert.Equal(0, s.Properties.GetInt(0x18u));
|
|
Assert.Equal(2, changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void DebitIntProperty_Absent_FalseAndNoEvent()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
int changed = 0;
|
|
s.CharacterChanged += () => changed++;
|
|
|
|
Assert.False(s.DebitIntProperty(0x18u, 1));
|
|
Assert.Equal(0, changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void DebitInt64Property_Present_DebitsAndFiresCharacterChanged()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var props = new PropertyBundle();
|
|
props.Int64s[2u] = 500L;
|
|
s.OnProperties(props);
|
|
int changed = 0;
|
|
s.CharacterChanged += () => changed++;
|
|
|
|
Assert.True(s.DebitInt64Property(2u, 100L));
|
|
Assert.Equal(400L, s.Properties.GetInt64(2u));
|
|
Assert.Equal(1, changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void DebitInt64Property_ZeroOrAbsent_False()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
Assert.False(s.DebitInt64Property(2u, 100L)); // absent
|
|
|
|
var props = new PropertyBundle();
|
|
props.Int64s[2u] = 0L;
|
|
s.OnProperties(props);
|
|
Assert.False(s.DebitInt64Property(2u, 100L)); // already 0 — no negative banking
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_ReturnsEveryCharacterSnapshotToPreLoginState()
|
|
{
|
|
var s = new LocalPlayerState();
|
|
var properties = new PropertyBundle();
|
|
properties.Ints[1u] = 42;
|
|
s.OnVitalUpdate(7u, 1u, 2u, 3u, 4u);
|
|
s.OnAttributeUpdate(1u, 5u, 6u, 7u);
|
|
s.OnSkillUpdate(8u, 9u, 2u, 10u, 11u, 12u, 13d, 14u);
|
|
s.OnPositions(new Dictionary<uint, AcDream.Core.Physics.Position>
|
|
{
|
|
[1u] = new AcDream.Core.Physics.Position(
|
|
0xA9B40001u,
|
|
new System.Numerics.Vector3(1f, 2f, 3f),
|
|
System.Numerics.Quaternion.Identity),
|
|
});
|
|
s.OnProperties(properties);
|
|
int characterChanges = 0;
|
|
var vitalChanges = new List<LocalPlayerState.VitalKind>();
|
|
var attributeChanges = new List<LocalPlayerState.AttributeKind>();
|
|
s.CharacterChanged += () => characterChanges++;
|
|
s.Changed += vitalChanges.Add;
|
|
s.AttributeChanged += attributeChanges.Add;
|
|
|
|
s.Clear();
|
|
|
|
Assert.Null(s.Get(LocalPlayerState.VitalKind.Health));
|
|
Assert.Null(s.GetAttribute(LocalPlayerState.AttributeKind.Strength));
|
|
Assert.Empty(s.Skills);
|
|
Assert.Empty(s.Positions);
|
|
Assert.Empty(s.Properties.Ints);
|
|
Assert.Equal(Enum.GetValues<LocalPlayerState.VitalKind>(), vitalChanges);
|
|
Assert.Equal(Enum.GetValues<LocalPlayerState.AttributeKind>(), attributeChanges);
|
|
Assert.Equal(1, characterChanges);
|
|
|
|
s.Clear();
|
|
Assert.Null(s.Get(LocalPlayerState.VitalKind.Health));
|
|
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);
|
|
}
|