acdream/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs
Erik 0fed5fdd91 fix(chargen): Campaign CC gate round 1 closeout — Group 2: Skills page four-bucket model
Ports the last remaining half of retail's Skills page: the four-bucket
sorted skill list (Specialized/Trained/UseableUntrained/UnuseableUntrained,
UpdateSkillEntry's own iMinlevel <= 1 test), plus the info box's
description + formula completion.

- ChargenSkillDetail/ChargenSkillFormula (Core) thread SkillBase.MinLevel/
  Description/Formula from the global SkillTable, exposed via a new
  ChargenOptions.TryGetSkillDetail (nullable-with-default parameter, so
  every pre-existing ChargenOptions call site compiles unchanged).
  ChargenTableReader.Project populates it from the same SkillTable loop
  that already builds GlobalSkillCostsBySkillId.
- CharacterCreationSkillsPage.RebuildRows now groups every costable skill
  into SkillBucket, sorts each bucket alphabetically by name
  (InsertEntrySorted's wcscmp, ported as string.CompareOrdinal), and
  builds one Templates[0] header row per bucket ahead of that bucket's
  Templates[1] skill rows — DoSkillRecords' own unconditional
  4-header-then-populate order. A level change re-buckets the row
  (detected per-refresh against each row's own cached bucket, then a
  full rebuild with the current selection explicitly preserved).
- RefreshInfoBox now composes description (word-wrapped via
  DatRichText.Compose) + the level-gated bonus line (an exact, unwrapped
  literal — NOT routed through word-wrap, which would have collapsed its
  authored double-space formatting) + ComposeFormula's "Formula : ..."
  line (MakeSkillFormula ported with high confidence for the prefix/
  per-attribute-term/divisor/bonus-suffix shape; the two-attribute
  connector text is a disclosed approximation, register AP-231, since
  the decompiled function's own connector literals could not be
  recovered byte-exact by this session's static-only tooling).

Register: AP-213 RETIRED (160 active rows). Live-DAT gate: the installed
SkillTable's MinLevel distribution matches the investigation's own
recorded finding exactly (38 entries, 23 useable-untrained / 15
trained-required). 3 new fixture tests + 1 new live-DAT test; 3
pre-existing integration tests fixed (they captured row widget
references before a bucket-changing click, which now rebuilds and
discards those references — a real, correct consequence of the new
model, not a bug).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:33:39 +02:00

392 lines
18 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>
/// Campaign CC gate round 1 closeout (Group 2): pins
/// <see cref="ChargenOptions.GlobalSkillDetailsBySkillId"/>'s real
/// shape against the installed DAT — same 38-entry population as
/// <see cref="ChargenOptions.GlobalSkillCostsBySkillId"/> (both read the
/// SAME SkillTable loop), MinLevel distributed 23 at <c>&lt;= 1</c>
/// (useable while Untrained) / 15 at exactly <c>2</c> (Trained
/// required), per the Batch F investigation's own recorded finding, and
/// never above 2 — <c>UpdateSkillEntry</c>'s own bucket test only ever
/// distinguishes <c>&lt;= 1</c> from <c>&gt; 1</c>, so a MinLevel of 3
/// would be observationally identical to 2 but is worth flagging if a
/// future DAT drop ever introduces one.
/// </summary>
[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.");
}
/// <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);
}
}