using AcDream.Core.Items; using AcDream.Core.Player; namespace AcDream.Core.Tests.Player; /// /// Tests for — per-vital + per-attribute /// cache populated from PlayerDescription's attribute block (ids /// 1..=6 primary attrs, 7..=9 vitals) and from /// PrivateUpdateVital(Current) deltas (ids 1..=6 in their /// alternate role as wire-opcode Vital ids). /// /// /// Max formula tested here: vital.(ranks+start) + attribute_contribution /// with retail coefficients (Endurance/2 for Health, Endurance for Stamina, /// Self for Mana). See class doc. /// /// 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(); 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(); 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 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 } }