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>
This commit is contained in:
parent
e1d7d09599
commit
0fed5fdd91
6 changed files with 712 additions and 121 deletions
|
|
@ -335,12 +335,20 @@ public sealed class CharacterCreationUiControllerTests
|
|||
environment.TabButton(CharacterCreationUiController.SkillsTabElementId)
|
||||
.OnClick!();
|
||||
|
||||
IReadOnlyList<UiElement> rows = environment.SkillsList().ViewportForTest!.Children;
|
||||
IReadOnlyList<UiElement> children = environment.SkillsList().ViewportForTest!.Children;
|
||||
// Group 2 closeout: the listbox now ALSO carries the four bucket-
|
||||
// header rows (DoSkillRecords' own unconditional 4-header build,
|
||||
// present regardless of whether a bucket is empty) — filter to
|
||||
// genuine skill rows (carry a 0x10000301 name descendant; headers
|
||||
// carry only 0x100002f6) before counting.
|
||||
List<UiElement> skillRows = [.. children.Where(
|
||||
candidate => UiElement.FindDescendant(candidate, 0x10000301u) is not null)];
|
||||
Assert.Equal(4, children.Count - skillRows.Count); // four bucket headers, always built.
|
||||
// Aluvian's fixture costs SkillTrainOnly(1)/SkillSpecializable(2)/
|
||||
// SkillFreeTrained(3, added for the F2 arrow-lock coverage below).
|
||||
Assert.Equal(3, rows.Count);
|
||||
Assert.Equal(3, skillRows.Count);
|
||||
|
||||
UiElement row = Assert.Single(rows, candidate =>
|
||||
UiElement row = Assert.Single(skillRows, candidate =>
|
||||
UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
|
||||
&& JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
|
||||
|
||||
|
|
@ -363,10 +371,21 @@ public sealed class CharacterCreationUiControllerTests
|
|||
// production's TrySetSkillLevel, RuntimeCharacterCreationState.cs
|
||||
// ~1045), so force one the same way the file's other post-click
|
||||
// refresh assertions do.
|
||||
//
|
||||
// Group 2 closeout: advancing also moves the row from the
|
||||
// UseableUntrained bucket to the Trained bucket, which rebuilds
|
||||
// every row — row/upCost/downCost captured above are now stale, so
|
||||
// re-fetch by name (the SAME lookup every other test in this file
|
||||
// uses post-rebuild) instead of reusing them.
|
||||
environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!();
|
||||
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
|
||||
environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 };
|
||||
environment.Controller.Tick();
|
||||
row = Assert.Single(environment.SkillsList().ViewportForTest!.Children, candidate =>
|
||||
UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
|
||||
&& JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
|
||||
upCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000303u));
|
||||
downCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000306u));
|
||||
Assert.Equal("4", JoinedText(upCost));
|
||||
Assert.Equal("2", JoinedText(downCost));
|
||||
}
|
||||
|
|
@ -377,10 +396,11 @@ public sealed class CharacterCreationUiControllerTests
|
|||
/// NAME text swaps to <c>Vector4.One</c> (the best-derived "brighter
|
||||
/// white") and the info title
|
||||
/// (<c>ShowSkillsText @0x00481250</c>'s <c>" (%d)"</c> score suffix)
|
||||
/// populates. Untrained/Inactive carries no bonus line, so the info
|
||||
/// TEXT pane stays blank (the still-missing description/formula halves
|
||||
/// — see <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own
|
||||
/// doc).</summary>
|
||||
/// populates. Group 2 closeout: Untrained/Inactive carries no BONUS
|
||||
/// line, but the info TEXT pane is no longer blank — the DESCRIPTION
|
||||
/// and <c>MakeSkillFormula</c> lines both render regardless of level
|
||||
/// (see <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own
|
||||
/// doc for the composition order).</summary>
|
||||
[Fact]
|
||||
public void SkillsPage_RowClick_SelectsRow_HighlightsNameAndPopulatesInfoBoxTitle()
|
||||
{
|
||||
|
|
@ -404,7 +424,13 @@ public sealed class CharacterCreationUiControllerTests
|
|||
string expectedTitle =
|
||||
$"{ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)} ({SkillTrainOnly * 10u})";
|
||||
Assert.Equal(expectedTitle, JoinedText(environment.SkillInfoTitle()));
|
||||
Assert.Equal(string.Empty, JoinedText(environment.SkillInfoText()));
|
||||
// SkillTrainOnly's fixture detail: description "A test skill
|
||||
// description.", formula (2 x Strength) / 4 +2, no bonus line
|
||||
// (Untrained). JoinedText's own single-space join collapses the
|
||||
// description's own word-wrapped line break.
|
||||
Assert.Equal(
|
||||
"A test skill description. Formula : (2 x Strength) / 4 +2",
|
||||
JoinedText(environment.SkillInfoText()));
|
||||
}
|
||||
|
||||
/// <summary>R2-4a: retail re-selects the row after an arrow click too
|
||||
|
|
@ -413,7 +439,11 @@ public sealed class CharacterCreationUiControllerTests
|
|||
/// IncreaseSkillLevel/DecreaseSkillLevel) — the info TEXT pane tracks
|
||||
/// the level-gated bonus line as the skill advances (the TWO-space
|
||||
/// literal <c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>,
|
||||
/// matching the compiled string verbatim).</summary>
|
||||
/// matching the compiled string verbatim, unwrapped end to end — see
|
||||
/// <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own doc for
|
||||
/// why the bonus line is never routed through the word-wrap the
|
||||
/// description segment gets). Group 2 closeout: the description and
|
||||
/// formula lines now bracket the bonus line on every assertion below.</summary>
|
||||
[Fact]
|
||||
public void SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine()
|
||||
{
|
||||
|
|
@ -426,11 +456,20 @@ public sealed class CharacterCreationUiControllerTests
|
|||
|
||||
up.OnClick!(); // Untrained/Inactive -> Trained.
|
||||
BumpRevisionAndTick(environment);
|
||||
Assert.Equal("Training Bonus +5", JoinedText(environment.SkillInfoText()));
|
||||
Assert.Equal(
|
||||
"A test skill description. Training Bonus +5 Formula : (2 x Strength) / 4 +2",
|
||||
JoinedText(environment.SkillInfoText()));
|
||||
|
||||
// Group 2 closeout: the Untrained -> Trained move above re-buckets
|
||||
// the row (rebuilding every row and nulling the OLD button's
|
||||
// OnClick as teardown) — re-fetch by name before the second click
|
||||
// instead of reusing the pre-rebuild `up` reference.
|
||||
(up, _) = environment.SkillRowArrows(SkillTrainOnly);
|
||||
up.OnClick!(); // Trained -> Specialized.
|
||||
BumpRevisionAndTick(environment);
|
||||
Assert.Equal("Specialization Bonus +10", JoinedText(environment.SkillInfoText()));
|
||||
Assert.Equal(
|
||||
"A test skill description. Specialization Bonus +10 Formula : (2 x Strength) / 4 +2",
|
||||
JoinedText(environment.SkillInfoText()));
|
||||
}
|
||||
|
||||
/// <summary>R2-4a: selecting a SECOND row restores the FIRST row's own
|
||||
|
|
@ -503,17 +542,26 @@ public sealed class CharacterCreationUiControllerTests
|
|||
Assert.Equal(0x1000001Bu, up.ActiveRetailStateId);
|
||||
Assert.Equal(0x1000001Au, down.ActiveRetailStateId);
|
||||
|
||||
// Group 2 closeout: advancing a skill can move its row into a NEW
|
||||
// bucket (UpdateSkillEntry's own re-bucket), which rebuilds every
|
||||
// row — up/down/freeUp/freeDown are re-fetched by name after each
|
||||
// level-changing click instead of reusing the pre-click widget
|
||||
// references, which would otherwise be silently stale (never
|
||||
// refreshed again once their row is discarded).
|
||||
up.OnClick!(); // -> Trained. trainedCost(2) != 0 -> Down enabled.
|
||||
BumpRevisionAndTick(environment);
|
||||
(up, down) = environment.SkillRowArrows(SkillTrainOnly);
|
||||
Assert.Equal(0x1000001Bu, down.ActiveRetailStateId);
|
||||
|
||||
(UiButton freeUp, UiButton freeDown) = environment.SkillRowArrows(SkillFreeTrained);
|
||||
freeUp.OnClick!(); // -> Trained. trainedCost(0) == 0 -> Down locked.
|
||||
BumpRevisionAndTick(environment);
|
||||
(freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained);
|
||||
Assert.Equal(0x1000001Au, freeDown.ActiveRetailStateId);
|
||||
|
||||
freeUp.OnClick!(); // -> Specialized. specCost(6) != 0 -> Down unlocks;
|
||||
BumpRevisionAndTick(environment); // Up is now ALWAYS ghosted.
|
||||
(freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained);
|
||||
Assert.Equal(0x1000001Au, freeUp.ActiveRetailStateId);
|
||||
Assert.Equal(0x1000001Bu, freeDown.ActiveRetailStateId);
|
||||
}
|
||||
|
|
@ -534,6 +582,96 @@ public sealed class CharacterCreationUiControllerTests
|
|||
Assert.Same(environment.SkillsList().Scroll, scrollbar.Model);
|
||||
}
|
||||
|
||||
// ── Campaign CC gate round 1 closeout: Group 2 (four-bucket model) ──
|
||||
|
||||
/// <summary><c>DoSkillRecords</c>'s own unconditional 4-header build —
|
||||
/// every bucket header is present, in Specialized/Trained/
|
||||
/// UseableUntrained/UnuseableUntrained order, even though this
|
||||
/// fixture's three skills leave the Specialized bucket empty.</summary>
|
||||
[Fact]
|
||||
public void SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_Specialized"] = "Specialized";
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_Trained"] = "Trained";
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_UnuseableUntrained"] = "Unuseable Untrained";
|
||||
environment.Controller.Open();
|
||||
environment.Runtime.SelectHeritageDirect(AluvianId);
|
||||
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
|
||||
|
||||
List<string> headerCaptions = [.. environment.SkillsList().ViewportForTest!.Children
|
||||
.Where(candidate => UiElement.FindDescendant(candidate, 0x10000301u) is null)
|
||||
.Select(candidate => Assert.IsType<UiButton>(
|
||||
UiElement.FindDescendant(candidate, 0x100002F6u)).Label!)];
|
||||
|
||||
Assert.Equal(
|
||||
["Specialized", "Trained", "Useable Untrained", "Unuseable Untrained"],
|
||||
headerCaptions);
|
||||
}
|
||||
|
||||
/// <summary><c>UpdateSkillEntry</c>'s own <c>iMinlevel <= 1</c> split:
|
||||
/// while Untrained, SkillTrainOnly (fixture MinLevel 1) is useable and
|
||||
/// SkillSpecializable (fixture MinLevel 2) is not — they land in
|
||||
/// DIFFERENT buckets even though both start Untrained (the default,
|
||||
/// unset, <c>FakeView.GetSkillLevel</c> state).</summary>
|
||||
[Fact]
|
||||
public void SkillsPage_UntrainedSkill_BucketsByMinLevel()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_UnuseableUntrained"] = "Unuseable Untrained";
|
||||
environment.Controller.Open();
|
||||
environment.Runtime.SelectHeritageDirect(AluvianId);
|
||||
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
|
||||
|
||||
List<UiElement> children = [.. environment.SkillsList().ViewportForTest!.Children];
|
||||
int useableHeaderIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Useable Untrained");
|
||||
int unuseableHeaderIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Unuseable Untrained");
|
||||
int trainOnlyRowIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x10000301u) is UiText n
|
||||
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
|
||||
int specializableRowIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x10000301u) is UiText n
|
||||
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable));
|
||||
|
||||
Assert.InRange(trainOnlyRowIndex, useableHeaderIndex + 1, unuseableHeaderIndex - 1);
|
||||
Assert.True(specializableRowIndex > unuseableHeaderIndex);
|
||||
}
|
||||
|
||||
/// <summary>Advancing a skill re-buckets its row — the row's position
|
||||
/// moves from the UseableUntrained section to the Trained section,
|
||||
/// matching <c>UpdateSkillEntry</c>'s own remove-and-reinsert (ported
|
||||
/// here as a detected full rebuild, not an incremental single-row
|
||||
/// move — see <see cref="CharacterCreationSkillsPage"/>'s own class doc
|
||||
/// for why that substitution is faithful to the OBSERVABLE result).</summary>
|
||||
[Fact]
|
||||
public void SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_Trained"] = "Trained";
|
||||
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
|
||||
environment.Controller.Open();
|
||||
environment.Runtime.SelectHeritageDirect(AluvianId);
|
||||
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
|
||||
|
||||
environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!(); // Untrained -> Trained.
|
||||
BumpRevisionAndTick(environment);
|
||||
|
||||
List<UiElement> children = [.. environment.SkillsList().ViewportForTest!.Children];
|
||||
int trainedHeaderIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Trained");
|
||||
int useableHeaderIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Useable Untrained");
|
||||
int rowIndex = children.FindIndex(c =>
|
||||
UiElement.FindDescendant(c, 0x10000301u) is UiText n
|
||||
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
|
||||
|
||||
Assert.InRange(rowIndex, trainedHeaderIndex + 1, useableHeaderIndex - 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TownButton_SelectsTheLiteralStartAreaIndex()
|
||||
{
|
||||
|
|
@ -2374,6 +2512,31 @@ public sealed class CharacterCreationUiControllerTests
|
|||
[SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 6),
|
||||
};
|
||||
|
||||
// Group 2 closeout: global SkillTable detail (MinLevel/
|
||||
// Description/Formula) — SkillTrainOnly is useable while
|
||||
// Untrained (MinLevel 1) and carries a real description +
|
||||
// single-attribute formula for the info-box completion tests;
|
||||
// SkillSpecializable requires Trained first (MinLevel 2), the
|
||||
// useable-vs-unuseable-untrained bucket split's own test case.
|
||||
var skillDetails = new Dictionary<uint, ChargenSkillDetail>
|
||||
{
|
||||
[SkillTrainOnly] = new ChargenSkillDetail(
|
||||
SkillTrainOnly,
|
||||
MinLevel: 1u,
|
||||
Description: "A test skill description.",
|
||||
Formula: new ChargenSkillFormula(
|
||||
AdditiveBonus: 2,
|
||||
Attribute1Multiplier: 2,
|
||||
Attribute2Multiplier: 0,
|
||||
Divisor: 4,
|
||||
Attribute1: (uint)ChargenAttributeId.Strength,
|
||||
Attribute2: 0u)),
|
||||
[SkillSpecializable] = new ChargenSkillDetail(
|
||||
SkillSpecializable, MinLevel: 2u, Description: string.Empty, Formula: default),
|
||||
[SkillFreeTrained] = new ChargenSkillDetail(
|
||||
SkillFreeTrained, MinLevel: 1u, Description: string.Empty, Formula: default),
|
||||
};
|
||||
|
||||
var aluvian = new ChargenHeritageOptions(
|
||||
AluvianId,
|
||||
"Aluvian",
|
||||
|
|
@ -2426,7 +2589,8 @@ public sealed class CharacterCreationUiControllerTests
|
|||
[AluvianId] = aluvian,
|
||||
[OlthoiId] = olthoi,
|
||||
},
|
||||
new Dictionary<uint, ChargenSkillCost>());
|
||||
new Dictionary<uint, ChargenSkillCost>(),
|
||||
skillDetails);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2806,16 +2970,22 @@ public sealed class CharacterCreationUiControllerTests
|
|||
return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root;
|
||||
}
|
||||
|
||||
return LayoutImporter.Build(
|
||||
new ElementInfo
|
||||
{
|
||||
Id = templateElementId,
|
||||
Type = 1u,
|
||||
Width = 280f,
|
||||
Height = 16f,
|
||||
},
|
||||
_ => (0u, 0, 0),
|
||||
null).Root;
|
||||
// Group 2 closeout: Templates[0] (0x100002F4) — retail's own
|
||||
// bucket-header row, now consumed by the four-bucket rebuild. A
|
||||
// plain container root (Type 3, same shape as Templates[1]'s own
|
||||
// row — a Type-1 UiButton root would CONSUME its own children and
|
||||
// hide the caption), carrying the caption child (0x100002f6, a
|
||||
// UiButton, read via .Label — CharacterCreationSkillsPage's own
|
||||
// HeaderCaptionElementId doc).
|
||||
var header = new ElementInfo
|
||||
{
|
||||
Id = templateElementId,
|
||||
Type = 3u,
|
||||
Width = 280f,
|
||||
Height = 16f,
|
||||
};
|
||||
header.Children.Add(ButtonInfo(0x100002F6u));
|
||||
return LayoutImporter.Build(header, _ => (0u, 0, 0), null).Root;
|
||||
}
|
||||
|
||||
// ── Summary page fixture (CC5) ───────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -277,6 +277,54 @@ public sealed class ChargenTableReaderInstalledDatTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue