Implements all six Opus review findings against 04450041 (Campaign CC
CC1 chargen data layer):
- F1 (HIGH, blocking): ChargenTemplate's doc claimed "Custom" has no
ChargenTemplate entry and cited two nonexistent addresses. Verified
against the named retail decomp: gmCGProfessionPage::UpdateProfession
@ 0x004821b0 resolves BOTH the highlighted button and the description
string from CharGenState.template_ 0..6, and case 0 is button
0x100003d9 / ID_CharGen_CustomText. Custom IS template index 0 (the
"Adventurer" row CC1 already found sitting at the attribute floor).
CharGenState::SetTemplate @ 0x005C5A60 confirms every button (including
Custom) calls CharGenState::ApplyTemplate @ 0x005C5080 when committing,
so selecting Custom resets the sliders/skills to that row rather than
leaving them untouched.
- F2 (MEDIUM): retail's skill-cost lookup is two-tiered
(ACCharGenData::GetSkillTrainedCost/GetSkillSpecializedCost @
0x005C26D0/0x005C27D0 fall through to the global SkillTable,
portal.dat 0x0E000004, on a heritage-list miss — confirmed against
ACE's identical PlayerFactory.cs precedence). ChargenTableReader now
also projects the global SkillTable into
ChargenOptions.GlobalSkillCostsBySkillId, and
ChargenSkillCreditMath.ComputeSpent/RemainingCredits check the
heritage list first and the global list on a miss. Added an
installed-DAT completeness assertion recording reality: the global
table prices 38/54 advancement skill ids, every one of the 13
installed heritages ships exactly one heritage-specific override
(always also priced globally), and 16 ids are genuinely uncostable in
both tiers. Also filed a CC7 risk-item note: ACE's own heritage-
override branch over-deducts on Specialize (PlayerFactory.cs:184-211)
— a retail-legal build may be rejected by local ACE at the CC7
connected gate; that is an ACE bug, not an acdream defect.
- F3 (MEDIUM): every collection ChargenTableReader hands into the
record model is now frozen at projection (ToFrozenDictionary/ToArray,
matching MagicCatalog's house pattern), including both
ChargenOptions.Empty dictionaries.
- F4 (LOW): added a reflection guard test
(ChargenNoChoriziteLeakTests) that walks every public
AcDream.Core.CharGen member (property/indexer/constructor/method
types, recursively through generic arguments) and fails if any
resolves to the DatReaderWriter or a Chorizite* assembly.
- F5 (LOW): ChargenGenderOptions.HasAnyAppearanceOptions's doc now
states precisely what the installed-DAT gate proves (an OR across
eight lists, for at least one gender per heritage) rather than the
stronger claim it previously made, and explicitly calls out the three
omitted color lists. Added a second installed-DAT gate that records
per-list reality across every gender of every heritage — found
complete, no empty lists anywhere in the installed DAT today.
- F6 (LOW): ChargenOptions.TryGetHeritage/TryGetStarterArea now use
[MaybeNullWhen(false)] instead of null! suppression, matching the
house pattern already used elsewhere in the test suite. Fixed every
call site this surfaced (more than the five originally estimated,
since Content.Tests has TreatWarningsAsErrors).
Core.Tests: 4737 passed / 1 skip (pre-existing, unrelated).
Content.Tests: 145 passed / 0 skip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
144 lines
5.8 KiB
C#
144 lines
5.8 KiB
C#
using AcDream.Core.CharGen;
|
|
|
|
namespace AcDream.Core.Tests.CharGen;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="ChargenSkillCreditMath"/> — retail's skill-credit
|
|
/// spend port (<c>CharGenState::UpdateRemainingSkillCredits @
|
|
/// 0x005C37C0</c>), including the two-tier heritage/global cost lookup
|
|
/// (<c>ACCharGenData::GetSkillTrainedCost @ 0x005C26D0</c> /
|
|
/// <c>GetSkillSpecializedCost @ 0x005C27D0</c>).
|
|
/// </summary>
|
|
public sealed class ChargenSkillCreditMathTests
|
|
{
|
|
private static readonly Dictionary<uint, ChargenSkillCost> 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.
|
|
};
|
|
|
|
/// <summary>Empty global fallback — tests that only exercise the
|
|
/// heritage tier pass this so a miss is a genuine both-tiers miss.</summary>
|
|
private static readonly Dictionary<uint, ChargenSkillCost> 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<uint, ChargenSkillCost>
|
|
{
|
|
[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<uint, ChargenSkillCost>
|
|
{
|
|
[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<uint, ChargenSkillCost>
|
|
{
|
|
[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));
|
|
}
|
|
}
|