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>
This commit is contained in:
parent
70d52b0da2
commit
cb4703e8d5
11 changed files with 637 additions and 139 deletions
|
|
@ -59,22 +59,22 @@ public sealed class ChargenTableReaderInstalledDatTests
|
|||
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(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(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(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(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);
|
||||
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]
|
||||
|
|
@ -178,9 +178,9 @@ public sealed class ChargenTableReaderInstalledDatTests
|
|||
{
|
||||
indicesChecked++;
|
||||
Assert.True(
|
||||
options.TryGetStarterArea(index, out ChargenStarterArea area),
|
||||
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.False(string.IsNullOrEmpty(area!.Name));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,8 +201,8 @@ public sealed class ChargenTableReaderInstalledDatTests
|
|||
using var adapter = new DatCollectionAdapter(dats);
|
||||
ChargenOptions options = ChargenTableReader.Load(adapter);
|
||||
|
||||
Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian));
|
||||
Assert.NotEmpty(aluvian.SkillCostsBySkillId);
|
||||
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);
|
||||
|
|
@ -211,4 +211,134 @@ public sealed class ChargenTableReaderInstalledDatTests
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,8 +203,8 @@ public sealed class ChargenTableReaderTests
|
|||
{
|
||||
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
|
||||
|
||||
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions heritage));
|
||||
Assert.Equal("Aluvian", heritage.Name);
|
||||
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage));
|
||||
Assert.Equal("Aluvian", heritage!.Name);
|
||||
Assert.Equal(0x06000001u, heritage.IconId);
|
||||
Assert.Equal(0x02000010u, heritage.SetupId);
|
||||
Assert.Equal(0x02000020u, heritage.EnvironmentSetupId);
|
||||
|
|
@ -219,10 +219,10 @@ public sealed class ChargenTableReaderTests
|
|||
public void Project_MapsSkillCostsKeyedByRawSkillId()
|
||||
{
|
||||
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
|
||||
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
|
||||
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage));
|
||||
|
||||
uint axeId = (uint)DatReaderWriter.Enums.SkillId.Axe;
|
||||
Assert.True(heritage.SkillCostsBySkillId.TryGetValue(axeId, out ChargenSkillCost cost));
|
||||
Assert.True(heritage!.SkillCostsBySkillId.TryGetValue(axeId, out ChargenSkillCost cost));
|
||||
Assert.Equal(axeId, cost.SkillId);
|
||||
Assert.Equal(4, cost.NormalCost);
|
||||
Assert.Equal(12, cost.PrimaryCost);
|
||||
|
|
@ -232,9 +232,9 @@ public sealed class ChargenTableReaderTests
|
|||
public void Project_MapsTemplateAttributesAndSkillLists()
|
||||
{
|
||||
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
|
||||
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
|
||||
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage));
|
||||
|
||||
ChargenTemplate template = Assert.Single(heritage.Templates);
|
||||
ChargenTemplate template = Assert.Single(heritage!.Templates);
|
||||
Assert.Equal("Soldier", template.Name);
|
||||
Assert.Equal(42u, template.TitleStringId);
|
||||
Assert.Equal(new ChargenAttributeValues(40, 40, 40, 20, 20, 20), template.Attributes);
|
||||
|
|
@ -247,9 +247,9 @@ public sealed class ChargenTableReaderTests
|
|||
public void Project_MapsGenderScalarsAndOptionLists()
|
||||
{
|
||||
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
|
||||
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
|
||||
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage));
|
||||
|
||||
Assert.True(heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender));
|
||||
Assert.True(heritage!.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender));
|
||||
Assert.Equal("Male", gender!.Name);
|
||||
Assert.Equal(1000000u, gender.Scale);
|
||||
Assert.Equal(0x02000030u, gender.SetupId);
|
||||
|
|
@ -288,8 +288,8 @@ public sealed class ChargenTableReaderTests
|
|||
public void Project_MapsObjDescPaletteSubPaletteTextureAndAnimPartChanges()
|
||||
{
|
||||
ChargenOptions options = ChargenTableReader.Project(BuildFixture());
|
||||
options.TryGetHeritage(1u, out ChargenHeritageOptions heritage);
|
||||
heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender);
|
||||
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage));
|
||||
heritage!.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender);
|
||||
|
||||
ChargenObjDesc baseDesc = gender!.BaseObjDesc;
|
||||
Assert.Equal(0x04000002u, baseDesc.PaletteId);
|
||||
|
|
|
|||
150
tests/AcDream.Core.Tests/CharGen/ChargenNoChoriziteLeakTests.cs
Normal file
150
tests/AcDream.Core.Tests/CharGen/ChargenNoChoriziteLeakTests.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
using System.Reflection;
|
||||
using AcDream.Core.CharGen;
|
||||
|
||||
namespace AcDream.Core.Tests.CharGen;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the CC1 no-leak contract with a real assertion rather than a doc
|
||||
/// comment. AcDream.Core references Chorizite.DatReaderWriter (for
|
||||
/// <c>TextureHelpers</c>), so "no Chorizite type on any public CharGen
|
||||
/// member" was convention only until this guard exists — nothing stopped a
|
||||
/// future edit from putting e.g. a <c>DatReaderWriter.Enums.SkillId</c>
|
||||
/// directly on a public property. This test walks every public type in the
|
||||
/// <c>AcDream.Core.CharGen</c> namespace and asserts that no public
|
||||
/// property, indexer, constructor parameter, or method return/parameter
|
||||
/// type — nor any of their generic type arguments, recursively — comes
|
||||
/// from the <c>DatReaderWriter</c> assembly or any assembly whose name
|
||||
/// starts with <c>Chorizite</c>.
|
||||
/// </summary>
|
||||
public sealed class ChargenNoChoriziteLeakTests
|
||||
{
|
||||
[Fact]
|
||||
public void PublicCharGenSurface_NeverExposesChoriziteOrDatReaderWriterTypes()
|
||||
{
|
||||
Assembly coreAssembly = typeof(ChargenOptions).Assembly;
|
||||
Type[] publicCharGenTypes = coreAssembly.GetTypes()
|
||||
.Where(t => t.IsPublic && t.Namespace == "AcDream.Core.CharGen")
|
||||
.ToArray();
|
||||
|
||||
// Guards the guard: if the namespace ever ends up empty (e.g. a
|
||||
// rename), this test must fail loudly rather than vacuously pass.
|
||||
Assert.True(
|
||||
publicCharGenTypes.Length > 5,
|
||||
$"Expected multiple public types in AcDream.Core.CharGen, found {publicCharGenTypes.Length}. " +
|
||||
"Did the namespace get renamed or moved?");
|
||||
|
||||
var offenders = new List<string>();
|
||||
|
||||
foreach (Type type in publicCharGenTypes)
|
||||
{
|
||||
foreach (PropertyInfo property in type.GetProperties(
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||
{
|
||||
CheckSite(property.PropertyType, $"{type.FullName}.{property.Name} (property)", offenders);
|
||||
|
||||
foreach (ParameterInfo indexParam in property.GetIndexParameters())
|
||||
{
|
||||
CheckSite(
|
||||
indexParam.ParameterType,
|
||||
$"{type.FullName}.{property.Name}[{indexParam.Name}] (indexer parameter)",
|
||||
offenders);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ConstructorInfo ctor in type.GetConstructors(
|
||||
BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
foreach (ParameterInfo param in ctor.GetParameters())
|
||||
{
|
||||
CheckSite(
|
||||
param.ParameterType,
|
||||
$"{type.FullName}..ctor({param.Name}) (constructor parameter)",
|
||||
offenders);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MethodInfo method in type.GetMethods(
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||
{
|
||||
// Property accessors, operators, and other compiler-emitted
|
||||
// members are IsSpecialName; the property/indexer loop above
|
||||
// already covers accessor types directly.
|
||||
if (method.IsSpecialName)
|
||||
continue;
|
||||
|
||||
CheckSite(method.ReturnType, $"{type.FullName}.{method.Name} (return type)", offenders);
|
||||
|
||||
foreach (ParameterInfo param in method.GetParameters())
|
||||
{
|
||||
CheckSite(
|
||||
param.ParameterType,
|
||||
$"{type.FullName}.{method.Name}({param.Name}) (method parameter)",
|
||||
offenders);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
offenders.Count == 0,
|
||||
"A Chorizite/DatReaderWriter type leaked onto a public AcDream.Core.CharGen member:\n"
|
||||
+ string.Join('\n', offenders));
|
||||
}
|
||||
|
||||
private static void CheckSite(Type type, string site, List<string> offenders)
|
||||
{
|
||||
foreach (Type candidate in FlattenTypeArguments(type))
|
||||
{
|
||||
string? assemblyName = candidate.Assembly.GetName().Name;
|
||||
if (assemblyName is null)
|
||||
continue;
|
||||
|
||||
bool isForbidden =
|
||||
assemblyName.Equals("DatReaderWriter", StringComparison.OrdinalIgnoreCase)
|
||||
|| assemblyName.StartsWith("Chorizite", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isForbidden)
|
||||
offenders.Add($"{site}: {candidate.FullName} (assembly '{assemblyName}')");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Yields <paramref name="type"/> itself plus every generic
|
||||
/// type argument, array element type, and by-ref (out/ref parameter)
|
||||
/// element type, recursively — so e.g. <c>out IReadOnlyDictionary
|
||||
/// <uint, ChargenSkillCost></c> is checked against
|
||||
/// <c>ChargenSkillCost</c>, not just the outer dictionary type.</summary>
|
||||
private static IEnumerable<Type> FlattenTypeArguments(Type type)
|
||||
{
|
||||
yield return type;
|
||||
|
||||
if (type.IsByRef || type.IsPointer)
|
||||
{
|
||||
Type? element = type.GetElementType();
|
||||
if (element is not null)
|
||||
{
|
||||
foreach (Type inner in FlattenTypeArguments(element))
|
||||
yield return inner;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (type.IsArray)
|
||||
{
|
||||
Type? element = type.GetElementType();
|
||||
if (element is not null)
|
||||
{
|
||||
foreach (Type inner in FlattenTypeArguments(element))
|
||||
yield return inner;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
foreach (Type argument in type.GetGenericArguments())
|
||||
{
|
||||
foreach (Type inner in FlattenTypeArguments(argument))
|
||||
yield return inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,9 +37,10 @@ public sealed class ChargenOptionsTests
|
|||
var aluvian = MakeHeritage(1u, "Aluvian");
|
||||
var options = new ChargenOptions(
|
||||
[],
|
||||
new Dictionary<uint, ChargenHeritageOptions> { [1u] = aluvian });
|
||||
new Dictionary<uint, ChargenHeritageOptions> { [1u] = aluvian },
|
||||
new Dictionary<uint, ChargenSkillCost>());
|
||||
|
||||
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions found));
|
||||
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? found));
|
||||
Assert.Same(aluvian, found);
|
||||
}
|
||||
|
||||
|
|
@ -55,9 +56,12 @@ public sealed class ChargenOptionsTests
|
|||
public void TryGetStarterArea_ResolvesByIndexAndRejectsOutOfRange()
|
||||
{
|
||||
var area = new ChargenStarterArea(0, "Holtburg", []);
|
||||
var options = new ChargenOptions([area], new Dictionary<uint, ChargenHeritageOptions>());
|
||||
var options = new ChargenOptions(
|
||||
[area],
|
||||
new Dictionary<uint, ChargenHeritageOptions>(),
|
||||
new Dictionary<uint, ChargenSkillCost>());
|
||||
|
||||
Assert.True(options.TryGetStarterArea(0, out ChargenStarterArea found));
|
||||
Assert.True(options.TryGetStarterArea(0, out ChargenStarterArea? found));
|
||||
Assert.Same(area, found);
|
||||
Assert.False(options.TryGetStarterArea(1, out _));
|
||||
Assert.False(options.TryGetStarterArea(-1, out _));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ namespace AcDream.Core.Tests.CharGen;
|
|||
/// <summary>
|
||||
/// Tests for <see cref="ChargenSkillCreditMath"/> — retail's skill-credit
|
||||
/// spend port (<c>CharGenState::UpdateRemainingSkillCredits @
|
||||
/// 0x005C37C0</c>).
|
||||
/// 0x005C37C0</c>), including the two-tier heritage/global cost lookup
|
||||
/// (<c>ACCharGenData::GetSkillTrainedCost @ 0x005C26D0</c> /
|
||||
/// <c>GetSkillSpecializedCost @ 0x005C27D0</c>).
|
||||
/// </summary>
|
||||
public sealed class ChargenSkillCreditMathTests
|
||||
{
|
||||
|
|
@ -17,6 +19,10 @@ public sealed class ChargenSkillCreditMathTests
|
|||
// Deliberately no entry for skill id 2 (Bow) — heritage doesn't offer it.
|
||||
};
|
||||
|
||||
/// <summary>Empty global fallback — tests that only exercise the
|
||||
/// heritage tier pass this so a miss is a genuine both-tiers miss.</summary>
|
||||
private static readonly Dictionary<uint, ChargenSkillCost> NoGlobalCosts = new();
|
||||
|
||||
[Fact]
|
||||
public void ComputeSpent_IgnoresInactiveAndUntrainedSkills()
|
||||
{
|
||||
|
|
@ -26,7 +32,7 @@ public sealed class ChargenSkillCreditMathTests
|
|||
[11u] = ChargenSkillAdvancementClass.Untrained,
|
||||
};
|
||||
|
||||
Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
|
||||
Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -34,7 +40,7 @@ public sealed class ChargenSkillCreditMathTests
|
|||
{
|
||||
var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained };
|
||||
|
||||
Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
|
||||
Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -44,7 +50,7 @@ public sealed class ChargenSkillCreditMathTests
|
|||
// both — PrimaryCost is the TOTAL cost to reach Specialized.
|
||||
var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Specialized };
|
||||
|
||||
Assert.Equal(12, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
|
||||
Assert.Equal(12, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -57,15 +63,67 @@ public sealed class ChargenSkillCreditMathTests
|
|||
[24u] = ChargenSkillAdvancementClass.Trained, // 1
|
||||
};
|
||||
|
||||
Assert.Equal(17, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
|
||||
Assert.Equal(17, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSpent_SkillWithNoCostEntryIsSkippedDefensively()
|
||||
public void ComputeSpent_SkillWithNoCostEntryInEitherTierIsSkipped()
|
||||
{
|
||||
// Retail's -1/"no cost" case: absent from BOTH the heritage list AND
|
||||
// the global SkillTable (ACCharGenData::GetSkillTrainedCost @
|
||||
// 0x005C26D0 returns 0xffffffff when even the global lookup misses).
|
||||
var advancement = new ChargenSkillAdvancementSet { [2u] = ChargenSkillAdvancementClass.Trained };
|
||||
|
||||
Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs));
|
||||
Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSpent_HeritageCostWinsOverGlobalCostWhenBothPresent()
|
||||
{
|
||||
// Skill id 1 (Axe) is priced differently by the heritage list and the
|
||||
// global SkillTable — retail's lookup checks the heritage's own list
|
||||
// FIRST and never consults the global table when the heritage
|
||||
// provides its own entry.
|
||||
var globalCosts = new Dictionary<uint, ChargenSkillCost>
|
||||
{
|
||||
[1u] = new ChargenSkillCost(1u, NormalCost: 999, PrimaryCost: 999),
|
||||
};
|
||||
var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained };
|
||||
|
||||
Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSpent_FallsBackToGlobalCostWhenHeritageListHasNoEntry()
|
||||
{
|
||||
// Skill id 2 (Bow) is absent from the heritage's own list but present
|
||||
// in the global SkillTable fallback — retail charges the global cost
|
||||
// rather than treating the skill as free.
|
||||
var globalCosts = new Dictionary<uint, ChargenSkillCost>
|
||||
{
|
||||
[2u] = new ChargenSkillCost(2u, NormalCost: 6, PrimaryCost: 18),
|
||||
};
|
||||
var advancement = new ChargenSkillAdvancementSet
|
||||
{
|
||||
[2u] = ChargenSkillAdvancementClass.Trained,
|
||||
};
|
||||
|
||||
Assert.Equal(6, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSpent_FallsBackToGlobalCostForSpecializedSkillsToo()
|
||||
{
|
||||
var globalCosts = new Dictionary<uint, ChargenSkillCost>
|
||||
{
|
||||
[2u] = new ChargenSkillCost(2u, NormalCost: 6, PrimaryCost: 18),
|
||||
};
|
||||
var advancement = new ChargenSkillAdvancementSet
|
||||
{
|
||||
[2u] = ChargenSkillAdvancementClass.Specialized,
|
||||
};
|
||||
|
||||
Assert.Equal(18, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -77,10 +135,10 @@ public sealed class ChargenSkillCreditMathTests
|
|||
[11u] = ChargenSkillAdvancementClass.Specialized,
|
||||
};
|
||||
|
||||
Assert.Equal(84, ChargenSkillCreditMath.RemainingCredits(100u, advancement, Costs));
|
||||
Assert.Equal(84, ChargenSkillCreditMath.RemainingCredits(100u, advancement, Costs, NoGlobalCosts));
|
||||
// Retail's Finish gate never checks remainingSkillCredits, so
|
||||
// overspending relative to the (small, synthetic) budget below is a
|
||||
// representable state, not a thrown exception.
|
||||
Assert.Equal(-16, ChargenSkillCreditMath.RemainingCredits(0u, advancement, Costs));
|
||||
Assert.Equal(-16, ChargenSkillCreditMath.RemainingCredits(0u, advancement, Costs, NoGlobalCosts));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue