acdream/tests/AcDream.Core.Tests/CharGen/ChargenAttributeMathTests.cs
Erik 0445004164 feat(content): Campaign CC CC1 — chargen table reader and typed options model
Adds the CC1 data layer for Campaign CC (retail character creation):
a reader for portal.dat's CharGen table (0x0E000002) plus a
presentation-free, Chorizite-free typed options model, and the pure
attribute/skill credit math the later CC3 Runtime owner needs.

Retail oracle (docs/research/named-retail/acclient_2013_pseudo_c.txt):
- ACCharGenData::Serialize @ 0x005C36D0 (table shape: StartingAreas +
  HeritageGroups)
- HeritageGroup_CG::Serialize @ 0x005C2100
- Sex_CG::Serialize @ 0x005C1600
- Template_CG::Serialize @ 0x005C0450
- CharGenState::SetHeritageGroup @ 0x005C67A0 and the six attribute-slider
  setters (~0x005C46CE..0x005C494E): remainingAtrbCredits = totalAtrbCredits
  - (str+end+coord+quick+focus+self) — a heritage's AttributeCredits is the
  budget the six RAW attribute values must fit, not points above the floor.
- CharGenState::Reset @ 0x005C68A0: atrbMin=10, atrbMax=100.
- gmCharGenMainUI::DoFinish @ 0x004E9170: Finish refuses only when
  remainingAtrbCredits > 0 (attributes only — skill credits are never
  gated to zero, confirmed by reading the function body).
- CharGenState::UpdateRemainingSkillCredits @ 0x005C37C0: exactly one of
  NormalCost/PrimaryCost is charged per Trained/Specialized skill.
- gmCGAppearancePage::Update @ 0x0047E8F0: the mHeritageGroup==0xc/0xd
  (Olthoi/OlthoiAcid) camera-offset branch CC6 will need.

Cross-checked against ACE's ACE.DatLoader.FileTypes.CharGen and
ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG/SkillCG loaders
(same field order, different byte format) and ACE.Entity.Enum.HeritageGroup
/ SkillAdvancementClass for the two small stable enums the model exposes.

src/AcDream.Core/CharGen/: ChargenOptions (root: StarterAreas +
HeritagesById), ChargenHeritageOptions, ChargenGenderOptions (BaseObjDesc
+ every appearance-option list: hair styles/colors, eye colors, eye/nose/
mouth strips, headgear/shirt/pants/footwear, clothing colors),
ChargenTemplate, ChargenObjDesc (palette/subpalette/texture/anim-part-swap
shape, mirrors PaletteOverride's presentation-free pattern), and the pure
math: ChargenAttributeMath (RemainingCredits/IsFullySpent/range checks) and
ChargenSkillCreditMath (retail's Trained-xor-Specialized cost sum) plus
ChargenSkillAdvancementSet, a structurally-fixed 55-slot type (reserved
slot 0 + SkillId 1..54) so CC2's future wire builder cannot send anything
but exactly 55 entries.

src/AcDream.Content/CharGen/ChargenTableReader.cs projects the Chorizite
DBObj graph into the Core model (MagicCatalog.Load's shape) — no Chorizite
type crosses into ChargenOptions.

Tests: hand-built-fixture unit tests for the pure math (Core.Tests) and the
Content projector (Content.Tests), plus six installed-DAT gate tests
(ContentConformanceDats pattern) against the real portal.dat: 13 heritage
groups (11 standard + 2 Olthoi), the four named heritages with retail
display names incl. "Gharu'ndim", every heritage has a gender with
non-empty appearance option lists, every template's attributes stay in
10..100 and never exceed its heritage's budget (discovered live: NOT every
template fully spends it — each human heritage's "Adventurer" template
sits at the floor as retail's real-DAT-backed "Custom" starting point),
start-area indices resolve into the shared list, and skill costs key to
valid 1..54 wire ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:53:50 +02:00

86 lines
3.1 KiB
C#

using AcDream.Core.CharGen;
namespace AcDream.Core.Tests.CharGen;
/// <summary>
/// Tests for <see cref="ChargenAttributeMath"/> — retail's attribute-credit
/// budget port (<c>CharGenState::SetHeritageGroup @ 0x005C67A0</c>,
/// <c>gmCharGenMainUI::DoFinish @ 0x004E9170</c>). Uses synthetic budgets so
/// these don't depend on real DAT values.
/// </summary>
public sealed class ChargenAttributeMathTests
{
[Fact]
public void RemainingCredits_IsBudgetMinusSumOfRawAttributeValues()
{
// Retail's floor is 10 per attribute; six attributes at the floor
// already "spend" 60 of the budget even before any points are added.
var values = new ChargenAttributeValues(10, 10, 10, 10, 10, 10);
int remaining = ChargenAttributeMath.RemainingCredits(180u, values);
Assert.Equal(120, remaining);
}
[Fact]
public void RemainingCredits_ZeroWhenValuesExactlyConsumeBudget()
{
var values = new ChargenAttributeValues(40, 40, 40, 20, 20, 20);
Assert.Equal(180, values.Total);
Assert.Equal(0, ChargenAttributeMath.RemainingCredits(180u, values));
}
[Fact]
public void IsFullySpent_TrueOnlyWhenRemainingIsExactlyZero()
{
var underspent = new ChargenAttributeValues(10, 10, 10, 10, 10, 10);
var exact = new ChargenAttributeValues(40, 40, 40, 20, 20, 20);
Assert.False(ChargenAttributeMath.IsFullySpent(180u, underspent));
Assert.True(ChargenAttributeMath.IsFullySpent(180u, exact));
}
[Fact]
public void IsFullySpent_FalseWhenCreditsRemain_MatchesRetailFinishGate()
{
// gmCharGenMainUI::DoFinish only refuses when remainingAtrbCredits
// > 0 (unspent credits) — it never special-cases "spent too much"
// because retail's own slider clamping never allows it.
var oneUnderBudget = new ChargenAttributeValues(40, 40, 40, 20, 20, 19);
Assert.False(ChargenAttributeMath.IsFullySpent(180u, oneUnderBudget));
Assert.Equal(1, ChargenAttributeMath.RemainingCredits(180u, oneUnderBudget));
}
[Theory]
[InlineData(9, false)]
[InlineData(10, true)]
[InlineData(100, true)]
[InlineData(101, false)]
public void IsWithinRange_EnforcesRetailFloorAndCeiling(int value, bool expected)
{
Assert.Equal(expected, ChargenAttributeMath.IsWithinRange(value));
}
[Fact]
public void AreAllWithinRange_FalseWhenAnySingleAttributeIsOutOfRange()
{
var withinRange = new ChargenAttributeValues(10, 100, 50, 50, 50, 50);
var oneTooLow = withinRange with { Focus = 9 };
var oneTooHigh = withinRange with { Self = 101 };
Assert.True(ChargenAttributeMath.AreAllWithinRange(withinRange));
Assert.False(ChargenAttributeMath.AreAllWithinRange(oneTooLow));
Assert.False(ChargenAttributeMath.AreAllWithinRange(oneTooHigh));
}
[Fact]
public void AttributeValues_TotalSumsAllSixInWireOrder()
{
var values = new ChargenAttributeValues(
Strength: 1, Endurance: 2, Coordination: 3, Quickness: 4, Focus: 5, Self: 6);
Assert.Equal(21, values.Total);
}
}