using AcDream.Content.CharGen;
using AcDream.Core.CharGen;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.Content.Tests.CharGen;
///
/// Installed-DAT gate for : proves the real
/// CharGen table (portal.dat 0x0E000002) loads through the SAME
/// production uses and lands in a
/// plausible shape. Env-gated like the rest of Content.Tests' installed-DAT
/// suite () — skips
/// cleanly (with a console note) when no DAT directory is configured,
/// matching e.g. PakEquivalenceTests / RetailDatLoaderTests.
///
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);
}
}
///
/// 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).
///
[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);
}
}
///
/// F2's completeness assertion: records EXACTLY what the installed DAT's
/// heritage-vs-global skill-cost coverage looks like (found
/// 2026-08-15), so 's two-tier
/// fallback (see )
/// 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.
///
[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();
foreach (KeyValuePair 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));
}
}
///
/// Campaign CC gate round 1 closeout (Group 2): pins
/// 's real
/// shape against the installed DAT — same 38-entry population as
/// (both read the
/// SAME SkillTable loop), MinLevel distributed 23 at <= 1
/// (useable while Untrained) / 15 at exactly 2 (Trained
/// required), per the Batch F investigation's own recorded finding, and
/// never above 2 — UpdateSkillEntry's own bucket test only ever
/// distinguishes <= 1 from > 1, so a MinLevel of 3
/// would be observationally identical to 2 but is worth flagging if a
/// future DAT drop ever introduces one.
///
[Fact]
public void InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage()
{
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(options.GlobalSkillCostsBySkillId.Count, options.GlobalSkillDetailsBySkillId!.Count);
Assert.Equal(38, options.GlobalSkillDetailsBySkillId.Count);
int useableUntrained = 0;
int trainedRequired = 0;
bool anyDescriptionNonEmpty = false;
foreach (ChargenSkillDetail detail in options.GlobalSkillDetailsBySkillId.Values)
{
Assert.True(detail.MinLevel <= 2u, $"skill {detail.SkillId}: MinLevel {detail.MinLevel} exceeds Trained(2).");
if (detail.MinLevel <= 1u)
useableUntrained++;
else
trainedRequired++;
anyDescriptionNonEmpty |= !string.IsNullOrEmpty(detail.Description);
}
Assert.Equal(23, useableUntrained);
Assert.Equal(15, trainedRequired);
Assert.True(anyDescriptionNonEmpty, "Expected at least one skill to carry a non-empty description.");
}
///
/// F5's strengthened installed-DAT gate.
/// only proves an OR across eight lists for at least one gender per
/// heritage (see
/// above). This test records the STRONGER fact actually found in the
/// installed DAT (2026-08-15): every one of the eight
/// HasAnyAppearanceOptions lists, PLUS the three color lists it
/// deliberately excludes (,
/// ,
/// ), 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.
///
[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();
foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values)
{
foreach (KeyValuePair 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);
}
}