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>
This commit is contained in:
parent
c3a8c231b8
commit
0445004164
19 changed files with 1656 additions and 0 deletions
|
|
@ -0,0 +1,86 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
77
tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs
Normal file
77
tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using AcDream.Core.CharGen;
|
||||
|
||||
namespace AcDream.Core.Tests.CharGen;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="ChargenOptions"/> / <see cref="ChargenHeritageOptions"/>
|
||||
/// lookup helpers, built from hand-crafted synthetic records (no DAT
|
||||
/// dependency — the installed-DAT read path is covered separately by
|
||||
/// AcDream.Content.Tests.CharGen).
|
||||
/// </summary>
|
||||
public sealed class ChargenOptionsTests
|
||||
{
|
||||
private static ChargenHeritageOptions MakeHeritage(uint id, string name) => new(
|
||||
HeritageId: id,
|
||||
Name: name,
|
||||
IconId: 0x06000001u,
|
||||
SetupId: 0x02000001u,
|
||||
EnvironmentSetupId: 0x02000002u,
|
||||
AttributeCredits: 180u,
|
||||
SkillCredits: 100u,
|
||||
PrimaryStartAreaIndices: [0],
|
||||
SecondaryStartAreaIndices: [],
|
||||
SkillCostsBySkillId: new Dictionary<uint, ChargenSkillCost>(),
|
||||
Templates: [],
|
||||
GendersByKey: new Dictionary<int, ChargenGenderOptions>());
|
||||
|
||||
[Fact]
|
||||
public void Empty_HasNoStarterAreasOrHeritages()
|
||||
{
|
||||
Assert.Empty(ChargenOptions.Empty.StarterAreas);
|
||||
Assert.Empty(ChargenOptions.Empty.HeritagesById);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetHeritage_FindsRegisteredHeritageById()
|
||||
{
|
||||
var aluvian = MakeHeritage(1u, "Aluvian");
|
||||
var options = new ChargenOptions(
|
||||
[],
|
||||
new Dictionary<uint, ChargenHeritageOptions> { [1u] = aluvian });
|
||||
|
||||
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions found));
|
||||
Assert.Same(aluvian, found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetHeritage_MissingIdReturnsFalse()
|
||||
{
|
||||
var options = ChargenOptions.Empty;
|
||||
|
||||
Assert.False(options.TryGetHeritage(1u, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetStarterArea_ResolvesByIndexAndRejectsOutOfRange()
|
||||
{
|
||||
var area = new ChargenStarterArea(0, "Holtburg", []);
|
||||
var options = new ChargenOptions([area], new Dictionary<uint, ChargenHeritageOptions>());
|
||||
|
||||
Assert.True(options.TryGetStarterArea(0, out ChargenStarterArea found));
|
||||
Assert.Same(area, found);
|
||||
Assert.False(options.TryGetStarterArea(1, out _));
|
||||
Assert.False(options.TryGetStarterArea(-1, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1u, false)] // Aluvian
|
||||
[InlineData(11u, false)] // Undead
|
||||
[InlineData(12u, true)] // Olthoi
|
||||
[InlineData(13u, true)] // OlthoiAcid
|
||||
public void IsOlthoi_TrueOnlyForTheTwoOlthoiVariants(uint heritageId, bool expected)
|
||||
{
|
||||
ChargenHeritageOptions heritage = MakeHeritage(heritageId, "Test");
|
||||
|
||||
Assert.Equal(expected, heritage.IsOlthoi);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
using AcDream.Core.CharGen;
|
||||
|
||||
namespace AcDream.Core.Tests.CharGen;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ChargenSkillAdvancementSet"/> — the structural
|
||||
/// 55-slot shape ACE's <c>CharacterCreateInfo.Unpack</c> requires on the
|
||||
/// 0xF656 wire (numSkills must be exactly 55: reserved slot 0 plus
|
||||
/// Chorizite's 54 named <c>SkillId</c> values, 1..54).
|
||||
/// </summary>
|
||||
public sealed class ChargenSkillAdvancementSetTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToWireClasses_AlwaysProducesExactlyFiftyFiveEntries()
|
||||
{
|
||||
var set = new ChargenSkillAdvancementSet();
|
||||
|
||||
Assert.Equal(55, set.ToWireClasses().Count);
|
||||
Assert.Equal(55, ChargenSkillAdvancementSet.SlotCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultState_EverySlotIsInactive()
|
||||
{
|
||||
var set = new ChargenSkillAdvancementSet();
|
||||
|
||||
IReadOnlyList<uint> wire = set.ToWireClasses();
|
||||
Assert.All(wire, value => Assert.Equal(0u, value));
|
||||
Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[1u]);
|
||||
Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[54u]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_RoundTripsAssignedSkillState()
|
||||
{
|
||||
var set = new ChargenSkillAdvancementSet
|
||||
{
|
||||
[1u] = ChargenSkillAdvancementClass.Trained,
|
||||
[54u] = ChargenSkillAdvancementClass.Specialized,
|
||||
};
|
||||
|
||||
Assert.Equal(ChargenSkillAdvancementClass.Trained, set[1u]);
|
||||
Assert.Equal(ChargenSkillAdvancementClass.Specialized, set[54u]);
|
||||
|
||||
IReadOnlyList<uint> wire = set.ToWireClasses();
|
||||
Assert.Equal(0u, wire[0]); // reserved slot never assignable
|
||||
Assert.Equal((uint)ChargenSkillAdvancementClass.Trained, wire[1]);
|
||||
Assert.Equal((uint)ChargenSkillAdvancementClass.Specialized, wire[54]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_ReservedSlotZero_ReadsInactiveAndCannotBeSet()
|
||||
{
|
||||
var set = new ChargenSkillAdvancementSet();
|
||||
|
||||
Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[0u]);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => set[0u] = ChargenSkillAdvancementClass.Trained);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(55u)]
|
||||
[InlineData(1000u)]
|
||||
public void Indexer_SetOutOfRange_Throws(uint skillId)
|
||||
{
|
||||
var set = new ChargenSkillAdvancementSet();
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => set[skillId] = ChargenSkillAdvancementClass.Trained);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_GetOutOfRange_ReadsInactiveWithoutThrowing()
|
||||
{
|
||||
var set = new ChargenSkillAdvancementSet();
|
||||
|
||||
Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[55u]);
|
||||
Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[uint.MaxValue]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
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>).
|
||||
/// </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.
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ComputeSpent_IgnoresInactiveAndUntrainedSkills()
|
||||
{
|
||||
var advancement = new ChargenSkillAdvancementSet
|
||||
{
|
||||
[1u] = ChargenSkillAdvancementClass.Inactive,
|
||||
[11u] = ChargenSkillAdvancementClass.Untrained,
|
||||
};
|
||||
|
||||
Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSpent_ChargesNormalCostForTrainedSkills()
|
||||
{
|
||||
var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained };
|
||||
|
||||
Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSpent_SkillWithNoCostEntryIsSkippedDefensively()
|
||||
{
|
||||
var advancement = new ChargenSkillAdvancementSet { [2u] = ChargenSkillAdvancementClass.Trained };
|
||||
|
||||
Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemainingCredits_IsTotalMinusSpent_AndMayGoNegativeUnlikeAttributes()
|
||||
{
|
||||
var advancement = new ChargenSkillAdvancementSet
|
||||
{
|
||||
[1u] = ChargenSkillAdvancementClass.Trained,
|
||||
[11u] = ChargenSkillAdvancementClass.Specialized,
|
||||
};
|
||||
|
||||
Assert.Equal(84, ChargenSkillCreditMath.RemainingCredits(100u, advancement, Costs));
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue