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

@ -26,10 +26,21 @@ namespace AcDream.Core.CharGen;
/// per-gender appearance option lists, skill costs — hangs off this one
/// root.
/// </summary>
/// <param name="GlobalSkillDetailsBySkillId">
/// Campaign CC gate round 1 closeout (Group 2): the global SkillTable's
/// MinLevel/Description/Formula per skill (see <see cref="ChargenSkillDetail"/>'s
/// own doc for why these are GLOBAL-only, unlike <paramref name="GlobalSkillCostsBySkillId"/>
/// which also has a per-heritage counterpart). Defaults to null (not an
/// empty dictionary) so every pre-existing caller that builds a
/// <see cref="ChargenOptions"/> without this parameter — five test fixtures
/// plus <c>ChargenOptions.Empty</c> below — compiles and behaves exactly as
/// before; <see cref="TryGetSkillDetail"/> treats null the same as "empty."
/// </param>
public sealed record ChargenOptions(
IReadOnlyList<ChargenStarterArea> StarterAreas,
IReadOnlyDictionary<uint, ChargenHeritageOptions> HeritagesById,
IReadOnlyDictionary<uint, ChargenSkillCost> GlobalSkillCostsBySkillId)
IReadOnlyDictionary<uint, ChargenSkillCost> GlobalSkillCostsBySkillId,
IReadOnlyDictionary<uint, ChargenSkillDetail>? GlobalSkillDetailsBySkillId = null)
{
public static ChargenOptions Empty { get; } = new(
Array.Empty<ChargenStarterArea>(),
@ -49,4 +60,13 @@ public sealed record ChargenOptions(
area = default;
return false;
}
/// <summary>See <see cref="GlobalSkillDetailsBySkillId"/>'s own doc.</summary>
public bool TryGetSkillDetail(uint skillId, [MaybeNullWhen(false)] out ChargenSkillDetail detail)
{
if (GlobalSkillDetailsBySkillId is { } details && details.TryGetValue(skillId, out detail))
return true;
detail = default;
return false;
}
}