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>
392 lines
18 KiB
C#
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><= 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><= 1</c> from <c>> 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);
|
|
}
|
|
}
|