acdream/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs
Erik cb4703e8d5 fix(chargen): CC1 review fix round — Custom is template 0, SkillTable cost fallback, frozen model
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>
2026-08-15 13:33:37 +02:00

344 lines
16 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);
}
}
/// <summary>
/// F2's completeness assertion: records EXACTLY what the installed DAT's
/// heritage-vs-global skill-cost coverage looks like (found
/// 2026-08-15), so <see cref="ChargenSkillCreditMath"/>'s two-tier
/// fallback (see <see cref="AcDream.Core.CharGen.ChargenOptions.GlobalSkillCostsBySkillId"/>)
/// has a real regression gate instead of only synthetic unit fixtures.
/// Findings: the global SkillTable (portal.dat 0x0E000004) prices 38 of
/// the 54 advancement skill ids; EVERY one of the 13 installed
/// heritages ships EXACTLY one heritage-specific skill-cost override
/// (never zero, never more), and that one override always ALSO has a
/// global entry — no heritage in this DAT relies on a heritage-only
/// price the global table doesn't know about. The remaining 16 skill
/// ids are absent from BOTH tiers in every heritage — retail's genuine
/// -1/"no cost" case (non-advancement or deprecated skill slots such as
/// the pre-Skill-DID-remap gaps). If a future DAT drop changes any of
/// this shape, this test should fail and get updated with the new
/// reality, not be loosened silently.
/// </summary>
[Fact]
public void InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet()
{
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.Equal(38, options.GlobalSkillCostsBySkillId.Count);
// Retail's -1/"no cost" case: absent from BOTH the global SkillTable
// AND every heritage's own list in the installed DAT.
uint[] expectedUncostableSkillIds =
[1, 2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 17, 25, 26, 42, 53];
Assert.NotEmpty(options.HeritagesById);
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
{
Assert.Single(heritage.SkillCostsBySkillId);
var uncostable = new List<uint>();
foreach (KeyValuePair<uint, ChargenSkillCost> pair in heritage.SkillCostsBySkillId)
{
Assert.True(
options.GlobalSkillCostsBySkillId.ContainsKey(pair.Key),
$"{heritage.Name}: skill {pair.Key} is heritage-only with no global fallback entry — " +
"new ground truth found; update this test's recorded reality.");
}
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
{
bool inHeritage = heritage.SkillCostsBySkillId.ContainsKey(skillId);
bool inGlobal = options.GlobalSkillCostsBySkillId.ContainsKey(skillId);
if (!inHeritage && !inGlobal)
uncostable.Add(skillId);
}
Assert.Equal(expectedUncostableSkillIds, uncostable.OrderBy(id => id));
}
}
/// <summary>
/// F5's strengthened installed-DAT gate. <see cref="ChargenGenderOptions.HasAnyAppearanceOptions"/>
/// only proves an OR across eight lists for at least one gender per
/// heritage (see <see cref="InstalledHeritages_EachHasAtLeastOneGenderWithNonEmptyAppearanceOptions"/>
/// above). This test records the STRONGER fact actually found in the
/// installed DAT (2026-08-15): every one of the eight
/// <c>HasAnyAppearanceOptions</c> lists, PLUS the three color lists it
/// deliberately excludes (<see cref="ChargenGenderOptions.HairColors"/>,
/// <see cref="ChargenGenderOptions.EyeColors"/>,
/// <see cref="ChargenGenderOptions.ClothingColors"/>), is non-empty for
/// EVERY gender of EVERY one of the 13 heritages — even the sparse ones
/// (Gear Knight and both Olthoi variants ship as few as 1-2 entries per
/// list, but never 0). This is a fact about today's data, not a
/// structural guarantee the DAT format enforces — the type's own doc
/// comment still warns callers to defend against an empty list
/// individually, and a future DAT could reintroduce a gap (e.g. a
/// bald-only heritage's hair styles). If this test starts failing on a
/// new DAT drop, that is this gate doing its job, not a reader bug.
/// </summary>
[Fact]
public void InstalledHeritages_AppearanceOptionListsRecordedPerListCompleteness()
{
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);
var emptyLists = new List<string>();
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
{
foreach (KeyValuePair<int, ChargenGenderOptions> genderPair in heritage.GendersByKey)
{
ChargenGenderOptions gender = genderPair.Value;
string label = $"{heritage.Name}/{gender.Name} (heritage={heritage.HeritageId}, gender={genderPair.Key})";
void RecordIfEmpty(string listName, int count)
{
if (count == 0)
emptyLists.Add($"{label}: {listName}");
}
RecordIfEmpty(nameof(gender.HairStyles), gender.HairStyles.Count);
RecordIfEmpty(nameof(gender.EyeStrips), gender.EyeStrips.Count);
RecordIfEmpty(nameof(gender.NoseStrips), gender.NoseStrips.Count);
RecordIfEmpty(nameof(gender.MouthStrips), gender.MouthStrips.Count);
RecordIfEmpty(nameof(gender.Headgears), gender.Headgears.Count);
RecordIfEmpty(nameof(gender.Shirts), gender.Shirts.Count);
RecordIfEmpty(nameof(gender.Pants), gender.Pants.Count);
RecordIfEmpty(nameof(gender.Footwear), gender.Footwear.Count);
RecordIfEmpty(nameof(gender.HairColors), gender.HairColors.Count);
RecordIfEmpty(nameof(gender.EyeColors), gender.EyeColors.Count);
RecordIfEmpty(nameof(gender.ClothingColors), gender.ClothingColors.Count);
}
}
Assert.Empty(emptyLists);
}
}