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>
214 lines
8.9 KiB
C#
214 lines
8.9 KiB
C#
using AcDream.Content.CharGen;
|
|
using AcDream.Core.CharGen;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.Options;
|
|
|
|
namespace AcDream.Content.Tests.CharGen;
|
|
|
|
/// <summary>
|
|
/// Installed-DAT gate for <see cref="ChargenTableReader"/>: proves the real
|
|
/// CharGen table (portal.dat 0x0E000002) loads through the SAME
|
|
/// <see cref="DatCollectionAdapter"/> production uses and lands in a
|
|
/// plausible shape. Env-gated like the rest of Content.Tests' installed-DAT
|
|
/// suite (<see cref="ContentConformanceDats.ResolveDatDir"/>) — skips
|
|
/// cleanly (with a console note) when no DAT directory is configured,
|
|
/// matching e.g. <c>PakEquivalenceTests</c> / <c>RetailDatLoaderTests</c>.
|
|
/// </summary>
|
|
public sealed class ChargenTableReaderInstalledDatTests
|
|
{
|
|
// ACE ACE.Entity.Enum.HeritageGroup — the four heritages CC1's spec
|
|
// calls out by name.
|
|
private const uint AluvianId = 1u;
|
|
private const uint GharundimId = 2u;
|
|
private const uint ShoId = 3u;
|
|
private const uint ViamontianId = 4u;
|
|
private const uint OlthoiId = 12u;
|
|
private const uint OlthoiAcidId = 13u;
|
|
|
|
[Fact]
|
|
public void InstalledCharGenTable_LoadsAndHasThirteenHeritageGroups()
|
|
{
|
|
string? datDir = ContentConformanceDats.ResolveDatDir();
|
|
if (datDir is null)
|
|
{
|
|
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
|
return;
|
|
}
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
using var adapter = new DatCollectionAdapter(dats);
|
|
|
|
ChargenOptions options = ChargenTableReader.Load(adapter);
|
|
|
|
// ACE's loader comment: "11 standard player races and 2 Olthoi".
|
|
Assert.Equal(13, options.HeritagesById.Count);
|
|
Assert.NotEmpty(options.StarterAreas);
|
|
}
|
|
|
|
[Fact]
|
|
public void InstalledCharGenTable_HasTheFourNamedHeritagesWithRetailDisplayNames()
|
|
{
|
|
string? datDir = ContentConformanceDats.ResolveDatDir();
|
|
if (datDir is null)
|
|
{
|
|
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
|
return;
|
|
}
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
using var adapter = new DatCollectionAdapter(dats);
|
|
ChargenOptions options = ChargenTableReader.Load(adapter);
|
|
|
|
Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian));
|
|
Assert.Equal("Aluvian", aluvian.Name);
|
|
|
|
Assert.True(options.TryGetHeritage(GharundimId, out ChargenHeritageOptions gharundim));
|
|
Assert.Equal("Gharu'ndim", gharundim.Name);
|
|
|
|
Assert.True(options.TryGetHeritage(ShoId, out ChargenHeritageOptions sho));
|
|
Assert.Equal("Sho", sho.Name);
|
|
|
|
Assert.True(options.TryGetHeritage(ViamontianId, out ChargenHeritageOptions viamontian));
|
|
Assert.Equal("Viamontian", viamontian.Name);
|
|
|
|
Assert.True(options.TryGetHeritage(OlthoiId, out ChargenHeritageOptions olthoi));
|
|
Assert.True(olthoi.IsOlthoi);
|
|
Assert.True(options.TryGetHeritage(OlthoiAcidId, out ChargenHeritageOptions olthoiAcid));
|
|
Assert.True(olthoiAcid.IsOlthoi);
|
|
}
|
|
|
|
[Fact]
|
|
public void InstalledHeritages_EachHasAtLeastOneGenderWithNonEmptyAppearanceOptions()
|
|
{
|
|
string? datDir = ContentConformanceDats.ResolveDatDir();
|
|
if (datDir is null)
|
|
{
|
|
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
|
return;
|
|
}
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
using var adapter = new DatCollectionAdapter(dats);
|
|
ChargenOptions options = ChargenTableReader.Load(adapter);
|
|
|
|
Assert.NotEmpty(options.HeritagesById);
|
|
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
|
|
{
|
|
Assert.NotEmpty(heritage.GendersByKey);
|
|
Assert.Contains(
|
|
heritage.GendersByKey.Values,
|
|
gender => gender.HasAnyAppearanceOptions);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every template's six attributes fall within retail's 10..100 range,
|
|
/// and no template's total exceeds its heritage's AttributeCredits
|
|
/// budget. NOT every template fully spends the budget — each of the
|
|
/// four human heritages (and Olthoi Acid) ships an "Adventurer" template
|
|
/// sitting at the floor (60 of a 330 budget; confirmed against the
|
|
/// installed DAT), which is retail's "Custom" starting point delivered
|
|
/// as a real TemplateCG entry rather than a special-cased UI-only
|
|
/// option. Every OTHER named human template (Bow Hunter, Swashbuckler,
|
|
/// Life Caster, War Mage, Wayfarer, Soldier) exactly exhausts its
|
|
/// heritage's credits, and Olthoi's single "Ripper" template exactly
|
|
/// exhausts its (much smaller, non-customizable) 60-credit budget — so
|
|
/// SOME template somewhere fully spends its budget, even though no
|
|
/// single heritage is guaranteed to (Olthoi Acid's only template is the
|
|
/// unspent "Adventurer" one).
|
|
/// </summary>
|
|
[Fact]
|
|
public void InstalledHeritages_EveryTemplateAttributeSpreadFitsTheAttributeBudget()
|
|
{
|
|
string? datDir = ContentConformanceDats.ResolveDatDir();
|
|
if (datDir is null)
|
|
{
|
|
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
|
return;
|
|
}
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
using var adapter = new DatCollectionAdapter(dats);
|
|
ChargenOptions options = ChargenTableReader.Load(adapter);
|
|
|
|
int templatesChecked = 0;
|
|
bool anyFullySpentAnywhere = false;
|
|
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
|
|
{
|
|
foreach (ChargenTemplate template in heritage.Templates)
|
|
{
|
|
templatesChecked++;
|
|
Assert.True(
|
|
ChargenAttributeMath.AreAllWithinRange(template.Attributes),
|
|
$"{heritage.Name}/{template.Name}: an attribute fell outside " +
|
|
$"{ChargenAttributeMath.AttributeMin}..{ChargenAttributeMath.AttributeMax} " +
|
|
$"({template.Attributes}).");
|
|
Assert.True(
|
|
template.Attributes.Total <= heritage.AttributeCredits,
|
|
$"{heritage.Name}/{template.Name}: attribute spread totals " +
|
|
$"{template.Attributes.Total}, exceeding the {heritage.AttributeCredits}-credit budget.");
|
|
anyFullySpentAnywhere |= ChargenAttributeMath.IsFullySpent(heritage.AttributeCredits, template.Attributes);
|
|
}
|
|
}
|
|
|
|
Assert.True(templatesChecked > 0, "Expected at least one profession template across all heritages.");
|
|
Assert.True(
|
|
anyFullySpentAnywhere,
|
|
"Expected at least one named preset template to fully spend its heritage's credit budget.");
|
|
}
|
|
|
|
[Fact]
|
|
public void InstalledHeritages_PrimaryAndSecondaryStartAreaIndicesResolveIntoTheSharedList()
|
|
{
|
|
string? datDir = ContentConformanceDats.ResolveDatDir();
|
|
if (datDir is null)
|
|
{
|
|
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
|
return;
|
|
}
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
using var adapter = new DatCollectionAdapter(dats);
|
|
ChargenOptions options = ChargenTableReader.Load(adapter);
|
|
|
|
int indicesChecked = 0;
|
|
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
|
|
{
|
|
foreach (int index in heritage.PrimaryStartAreaIndices.Concat(heritage.SecondaryStartAreaIndices))
|
|
{
|
|
indicesChecked++;
|
|
Assert.True(
|
|
options.TryGetStarterArea(index, out ChargenStarterArea area),
|
|
$"{heritage.Name}: start-area index {index} does not resolve into the shared StarterAreas list.");
|
|
Assert.False(string.IsNullOrEmpty(area.Name));
|
|
}
|
|
}
|
|
|
|
Assert.True(indicesChecked > 0, "Expected at least one heritage start-area reference.");
|
|
}
|
|
|
|
[Fact]
|
|
public void InstalledHeritages_SkillCostsResolveToKnownWireSkillIds()
|
|
{
|
|
string? datDir = ContentConformanceDats.ResolveDatDir();
|
|
if (datDir is null)
|
|
{
|
|
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
|
return;
|
|
}
|
|
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
using var adapter = new DatCollectionAdapter(dats);
|
|
ChargenOptions options = ChargenTableReader.Load(adapter);
|
|
|
|
Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian));
|
|
Assert.NotEmpty(aluvian.SkillCostsBySkillId);
|
|
foreach (var pair in aluvian.SkillCostsBySkillId)
|
|
{
|
|
Assert.Equal(pair.Key, pair.Value.SkillId);
|
|
Assert.InRange(pair.Key, 1u, 54u);
|
|
Assert.True(pair.Value.NormalCost >= 0);
|
|
Assert.True(pair.Value.PrimaryCost >= 0);
|
|
}
|
|
}
|
|
}
|