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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,53 @@ public enum ChargenSkillAdvancementClass : uint
|
|||
/// </summary>
|
||||
public readonly record struct ChargenSkillCost(uint SkillId, int NormalCost, int PrimaryCost);
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmCGSkillsPage::MakeSkillFormula @0x00480e10</c>'s six raw inputs —
|
||||
/// retail's <c>SkillFormula</c> struct (<c>acclient.h</c>) verbatim field
|
||||
/// order/shape: <c>_w</c>=<see cref="AdditiveBonus"/>,
|
||||
/// <c>_x</c>=<see cref="Attribute1Multiplier"/>,
|
||||
/// <c>_y</c>=<see cref="Attribute2Multiplier"/>, <c>_z</c>=<see cref="Divisor"/>,
|
||||
/// <c>_attr1</c>=<see cref="Attribute1"/>, <c>_attr2</c>=<see cref="Attribute2"/>.
|
||||
/// <see cref="Attribute1"/>/<see cref="Attribute2"/> are the raw 1-6 retail
|
||||
/// attribute id (matching <c>AcDream.Runtime.Session.ChargenAttributeId</c>'s
|
||||
/// own numbering exactly — Strength=1..Self=6) rather than that enum type
|
||||
/// itself, since Core does not (and must not) reference Runtime; the App
|
||||
/// layer, which already references both, does the enum cast at the one
|
||||
/// call site that needs an attribute NAME.
|
||||
/// </summary>
|
||||
public readonly record struct ChargenSkillFormula(
|
||||
int AdditiveBonus,
|
||||
int Attribute1Multiplier,
|
||||
int Attribute2Multiplier,
|
||||
int Divisor,
|
||||
uint Attribute1,
|
||||
uint Attribute2);
|
||||
|
||||
/// <summary>
|
||||
/// One skill's GLOBAL (heritage-independent) presentation data — retail's
|
||||
/// <c>SkillBase._min_level</c>/<c>_description</c>/<c>_formula</c> fields,
|
||||
/// sourced ONLY from the portal.dat SkillTable. Distinct from
|
||||
/// <see cref="ChargenSkillCost"/> (which exists BOTH per-heritage
|
||||
/// (<c>SkillCG</c>) AND globally) because these three fields have NO
|
||||
/// per-heritage override in retail at all — <c>SkillCG</c> (the per-
|
||||
/// heritage cost record <c>HeritageGroupCG.Skills</c> projects) carries
|
||||
/// only <c>Id</c>/<c>NormalCost</c>/<c>PrimaryCost</c>, verified against the
|
||||
/// DatReaderWriter binding.
|
||||
/// </summary>
|
||||
/// <param name="MinLevel">
|
||||
/// Retail's <c>_min_level</c> is typed <c>SKILL_ADVANCEMENT_CLASS</c>, not a
|
||||
/// character level — <c>gmCGSkillsPage::UpdateSkillEntry @0x00480bf0</c>'s
|
||||
/// own bucket test (<c>arg2->iMinlevel <= 1</c>) reads it as "the
|
||||
/// lowest <see cref="ChargenSkillAdvancementClass"/> at which this skill is
|
||||
/// USEABLE" — <c><= 1</c> (Inactive/Untrained) means useable while
|
||||
/// untrained, <c>== 2</c> (Trained) means training is required first.
|
||||
/// </param>
|
||||
public readonly record struct ChargenSkillDetail(
|
||||
uint SkillId,
|
||||
uint MinLevel,
|
||||
string Description,
|
||||
ChargenSkillFormula Formula);
|
||||
|
||||
/// <summary>
|
||||
/// Retail's fixed-size per-character skill-advancement array
|
||||
/// (<c>CharGenState.skillLevels</c>). ACE's <c>CharacterCreateInfo.Unpack</c>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue