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