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:
Erik 2026-08-16 15:33:39 +02:00
parent e1d7d09599
commit 0fed5fdd91
6 changed files with 712 additions and 121 deletions

View file

@ -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 &lt;= 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) ───────────────────────────────────────

View file

@ -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>&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