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>
72 lines
3.6 KiB
C#
72 lines
3.6 KiB
C#
using System.Collections.Frozen;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
|
|
namespace AcDream.Core.CharGen;
|
|
|
|
/// <summary>
|
|
/// Top-level, presentation-free, immutable projection of retail's CharGen
|
|
/// DAT table (portal.dat 0x0E000002, <c>ACCharGenData::Serialize @
|
|
/// 0x005C36D0</c>) PLUS the global SkillTable (portal.dat 0x0E000004) that
|
|
/// retail falls back to when a heritage's own skill-cost list has no entry
|
|
/// for a given skill id. Retail's <c>ACCharGenData::GetSkillTrainedCost @
|
|
/// 0x005C26D0</c> / <c>GetSkillSpecializedCost @ 0x005C27D0</c> both scan
|
|
/// the heritage's own list first and, on a miss (or an empty list), fall
|
|
/// through to <c>DBCache::GetFromEnumStatic(4, 2, 0x10000004)</c> — the
|
|
/// SAME global SkillTable every other skill-cost lookup in the client
|
|
/// reads — rather than treating the skill as free or invalid. See
|
|
/// <see cref="GlobalSkillCostsBySkillId"/>. Production builds create this
|
|
/// from the installed DAT through Content's
|
|
/// <c>AcDream.Content.CharGen.ChargenTableReader.Load</c>; this type itself
|
|
/// has no DAT/Chorizite dependency so it is safe to hand to plugin-facing
|
|
/// or test code. Every collection is frozen/immutable at construction (a
|
|
/// caller cannot downcast an <see cref="IReadOnlyDictionary{TKey,TValue}"/>
|
|
/// back to a mutable <see cref="Dictionary{TKey,TValue}"/> and mutate this
|
|
/// process-shared model out from under other readers). Everything a "typed
|
|
/// chargen options model" needs — starter areas, heritages, templates,
|
|
/// 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, ChargenSkillDetail>? GlobalSkillDetailsBySkillId = null)
|
|
{
|
|
public static ChargenOptions Empty { get; } = new(
|
|
Array.Empty<ChargenStarterArea>(),
|
|
FrozenDictionary<uint, ChargenHeritageOptions>.Empty,
|
|
FrozenDictionary<uint, ChargenSkillCost>.Empty);
|
|
|
|
public bool TryGetHeritage(uint heritageId, [MaybeNullWhen(false)] out ChargenHeritageOptions heritage) =>
|
|
HeritagesById.TryGetValue(heritageId, out heritage);
|
|
|
|
public bool TryGetStarterArea(int index, [MaybeNullWhen(false)] out ChargenStarterArea area)
|
|
{
|
|
if (index >= 0 && index < StarterAreas.Count)
|
|
{
|
|
area = StarterAreas[index];
|
|
return true;
|
|
}
|
|
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;
|
|
}
|
|
}
|