using AcDream.Core.CharGen; namespace AcDream.Core.Tests.CharGen; /// /// Tests for — retail's skill-credit /// spend port (CharGenState::UpdateRemainingSkillCredits @ /// 0x005C37C0), including the two-tier heritage/global cost lookup /// (ACCharGenData::GetSkillTrainedCost @ 0x005C26D0 / /// GetSkillSpecializedCost @ 0x005C27D0). /// public sealed class ChargenSkillCreditMathTests { private static readonly Dictionary Costs = new() { [1u] = new ChargenSkillCost(1u, NormalCost: 4, PrimaryCost: 12), // Axe [11u] = new ChargenSkillCost(11u, NormalCost: 4, PrimaryCost: 12), // Sword [24u] = new ChargenSkillCost(24u, NormalCost: 1, PrimaryCost: 3), // Run // Deliberately no entry for skill id 2 (Bow) — heritage doesn't offer it. }; /// Empty global fallback — tests that only exercise the /// heritage tier pass this so a miss is a genuine both-tiers miss. private static readonly Dictionary NoGlobalCosts = new(); [Fact] public void ComputeSpent_IgnoresInactiveAndUntrainedSkills() { var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Inactive, [11u] = ChargenSkillAdvancementClass.Untrained, }; Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts)); } [Fact] public void ComputeSpent_ChargesNormalCostForTrainedSkills() { var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained }; Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts)); } [Fact] public void ComputeSpent_ChargesPrimaryCostInsteadOfNormalCostForSpecializedSkills() { // Retail adds exactly one of NormalCost/PrimaryCost per skill, never // both — PrimaryCost is the TOTAL cost to reach Specialized. var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Specialized }; Assert.Equal(12, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts)); } [Fact] public void ComputeSpent_SumsAcrossMultipleTrainedAndSpecializedSkills() { var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained, // 4 [11u] = ChargenSkillAdvancementClass.Specialized, // 12 [24u] = ChargenSkillAdvancementClass.Trained, // 1 }; Assert.Equal(17, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts)); } [Fact] public void ComputeSpent_SkillWithNoCostEntryInEitherTierIsSkipped() { // Retail's -1/"no cost" case: absent from BOTH the heritage list AND // the global SkillTable (ACCharGenData::GetSkillTrainedCost @ // 0x005C26D0 returns 0xffffffff when even the global lookup misses). var advancement = new ChargenSkillAdvancementSet { [2u] = ChargenSkillAdvancementClass.Trained }; Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts)); } [Fact] public void ComputeSpent_HeritageCostWinsOverGlobalCostWhenBothPresent() { // Skill id 1 (Axe) is priced differently by the heritage list and the // global SkillTable — retail's lookup checks the heritage's own list // FIRST and never consults the global table when the heritage // provides its own entry. var globalCosts = new Dictionary { [1u] = new ChargenSkillCost(1u, NormalCost: 999, PrimaryCost: 999), }; var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained }; Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts)); } [Fact] public void ComputeSpent_FallsBackToGlobalCostWhenHeritageListHasNoEntry() { // Skill id 2 (Bow) is absent from the heritage's own list but present // in the global SkillTable fallback — retail charges the global cost // rather than treating the skill as free. var globalCosts = new Dictionary { [2u] = new ChargenSkillCost(2u, NormalCost: 6, PrimaryCost: 18), }; var advancement = new ChargenSkillAdvancementSet { [2u] = ChargenSkillAdvancementClass.Trained, }; Assert.Equal(6, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts)); } [Fact] public void ComputeSpent_FallsBackToGlobalCostForSpecializedSkillsToo() { var globalCosts = new Dictionary { [2u] = new ChargenSkillCost(2u, NormalCost: 6, PrimaryCost: 18), }; var advancement = new ChargenSkillAdvancementSet { [2u] = ChargenSkillAdvancementClass.Specialized, }; Assert.Equal(18, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts)); } [Fact] public void RemainingCredits_IsTotalMinusSpent_AndMayGoNegativeUnlikeAttributes() { var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained, [11u] = ChargenSkillAdvancementClass.Specialized, }; Assert.Equal(84, ChargenSkillCreditMath.RemainingCredits(100u, advancement, Costs, NoGlobalCosts)); // Retail's Finish gate never checks remainingSkillCredits, so // overspending relative to the (small, synthetic) budget below is a // representable state, not a thrown exception. Assert.Equal(-16, ChargenSkillCreditMath.RemainingCredits(0u, advancement, Costs, NoGlobalCosts)); } }