Fills TS-82's Summary placeholder with a faithful port of gmCGSummaryPage (name field with NameInputFilter + the retail commit-on-focus-lost/submit dispatch + the >32-char ID_CharGen_NameTooLong reject-and-revert path, the REAL three-row-template listbox confirmed against the installed EoR dat before writing any page code, and Summary's own independent gmCG3DView preview instance wired through a second ChargenPreviewController pair mirroring the Appearance page's exact composition shape). Ports CharGenState::RandomizeCharacter and its six sub-primitives into RuntimeCharacterCreationState — not approximated: the RandInt/RollDice semantics are independently confirmed from both the decompiled RNG bodies and the CharGenStateVtbl union struct in acclient.h. Three consumers: the chargen screen's open-roll (retiring AP-214's honest-blank deviation and reproducing the Appearance page's gender-flip-on-init quirk), the Summary page's Random button (behind the retail randomize-warning confirm), and the Appearance page's Random button (narrowing AP-212 to just Heritage/Profession/Town's still-approximated rolls and Skills' still-unported RandomizeSkills). Wires the Finish button (previously ghosted) with retail's NoName/ CreditWarning dialog pair, adds the F12 amendment's HeritageOrGenderUnset local refusal to TryBeginFinish (register AP-223) as a defensive backstop now that the screen-open roll normally makes it unreachable, and wires the four ID_Character_Err_* rejection dialogs for the 0xF643 response codes CC3 already parsed but nothing displayed. Register: TS-82 retired, AP-214 retired, AP-212 narrowed, AP-223/224/225 filed (heritage/gender Finish refusal, Summary's two-bucket skill-list narrowing, the 32-vs-33 name-length threshold reconciliation). Runtime 1722/0 (was 1713), App 5240/3 skips (was 5223/3), Headless 166/0 unchanged, full solution Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
253 lines
11 KiB
C#
253 lines
11 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.CharGen;
|
|
|
|
namespace AcDream.Runtime.Tests.CharGen;
|
|
|
|
/// <summary>
|
|
/// A small, hand-authored <see cref="ChargenOptions"/> fixture for
|
|
/// <see cref="RuntimeCharacterCreationState"/> tests — NOT installed-DAT data
|
|
/// (that is CC1's <c>ChargenTableReaderInstalledDatTests</c> concern). Shapes
|
|
/// mirror retail's field semantics closely enough to exercise every gate:
|
|
/// two heritages (a normal one and an Olthoi variant that must force template
|
|
/// 0), a "Custom"/floor template plus a str-heavy preset, a mix of costed
|
|
/// (including two FREE skills exercising every <c>ResetSkillLevels</c>
|
|
/// baseline branch) and deliberately uncostable skill ids, and one gender
|
|
/// with small appearance-option lists to exercise index bounds.
|
|
/// </summary>
|
|
internal static class RuntimeCharacterCreationStateFixture
|
|
{
|
|
public const uint AluvianId = 1u;
|
|
public const uint OlthoiId = (uint)ChargenHeritageGroup.Olthoi;
|
|
public const uint ImpoverishedId = 90u;
|
|
public const uint MaleGenderKey = 1u;
|
|
|
|
/// <summary>Campaign CC slice CC5: <c>RandomizeCharacter</c>'s gender
|
|
/// roll (<c>RollDice(1,2)</c>) needs BOTH gender keys resolvable on the
|
|
/// four human heritages, or half of all random seeds would land on a
|
|
/// gender <see cref="ChargenHeritageOptions.GendersByKey"/> can't
|
|
/// resolve and silently leave appearance untouched (a real, harmless
|
|
/// <c>ConstrainAllByGender</c>-style fallback — but not what these tests
|
|
/// are pinning).</summary>
|
|
public const uint FemaleGenderKey = 2u;
|
|
|
|
/// <summary>str=10 end=10 coord=10 quick=10 focus=10 self=10 — the
|
|
/// budget-66 heritage leaves 6 credits unspent after this template,
|
|
/// matching retail's "Custom sits at the floor" finding.</summary>
|
|
public const uint CustomTemplateIndex = 0u;
|
|
|
|
/// <summary>str=16, everything else at floor — sum 66 (fully spent).</summary>
|
|
public const uint PresetTemplateIndex = 1u;
|
|
|
|
/// <summary>Costed only by the heritage's own list — Normal 4 / Primary 12.</summary>
|
|
public const uint SkillTrainSpecialize = 1u;
|
|
|
|
/// <summary>The Preset template's Primary (specialized) skill.</summary>
|
|
public const uint SkillPresetPrimary = 2u;
|
|
|
|
/// <summary>FREE and pre-specialized by <c>ResetSkillLevels</c>'s baseline
|
|
/// (NormalCost=0, PrimaryCost<=0).</summary>
|
|
public const uint SkillFreeSpecialized = 3u;
|
|
|
|
/// <summary>FREE to train, costs to specialize
|
|
/// (NormalCost=0, PrimaryCost>0).</summary>
|
|
public const uint SkillFreeTrained = 4u;
|
|
|
|
/// <summary>Uncostable in BOTH tiers — must never be settable (the CC1
|
|
/// R1 binding fact: keep the both-miss refund path unreachable).</summary>
|
|
public const uint SkillUncostable = 5u;
|
|
|
|
/// <summary>The Custom template's authored Normal skill.</summary>
|
|
public const uint SkillCustomNormal = 24u;
|
|
|
|
public static ChargenOptions Build()
|
|
{
|
|
var starterAreas = new List<ChargenStarterArea>
|
|
{
|
|
new(0, "Holtburg", [new ChargenPosition(1u, Vector3.Zero, Quaternion.Identity)]),
|
|
new(1, "Yaraq", [new ChargenPosition(2u, Vector3.Zero, Quaternion.Identity)]),
|
|
};
|
|
|
|
var skillCosts = new Dictionary<uint, ChargenSkillCost>
|
|
{
|
|
[SkillTrainSpecialize] = new(SkillTrainSpecialize, NormalCost: 4, PrimaryCost: 12),
|
|
[SkillPresetPrimary] = new(SkillPresetPrimary, NormalCost: 3, PrimaryCost: 9),
|
|
[SkillFreeSpecialized] = new(SkillFreeSpecialized, NormalCost: 0, PrimaryCost: 0),
|
|
[SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 5),
|
|
[SkillCustomNormal] = new(SkillCustomNormal, NormalCost: 2, PrimaryCost: 6),
|
|
};
|
|
|
|
var gender = new ChargenGenderOptions(
|
|
GenderKey: (int)MaleGenderKey,
|
|
Name: "Male",
|
|
Scale: 1u,
|
|
SetupId: 0x2000054u,
|
|
SoundTableId: 0u,
|
|
IconId: 0u,
|
|
BasePaletteId: 0u,
|
|
SkinPalSetId: 0u,
|
|
PhysicsTableId: 0u,
|
|
MotionTableId: 0u,
|
|
CombatTableId: 0u,
|
|
BaseObjDesc: ChargenObjDesc.Empty,
|
|
HairColors: [100u, 101u],
|
|
HairStyles:
|
|
[
|
|
new ChargenHairStyle(1u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty),
|
|
new ChargenHairStyle(2u, Bald: true, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty),
|
|
],
|
|
EyeColors: [200u, 201u],
|
|
EyeStrips:
|
|
[
|
|
new ChargenEyeStrip(1u, 2u, ChargenObjDesc.Empty, ChargenObjDesc.Empty),
|
|
],
|
|
NoseStrips: [new ChargenFaceStrip(1u, ChargenObjDesc.Empty)],
|
|
MouthStrips: [new ChargenFaceStrip(1u, ChargenObjDesc.Empty)],
|
|
Headgears: [new ChargenGearOption("Cap", 1u, 300u)],
|
|
Shirts: [new ChargenGearOption("Shirt", 2u, 301u)],
|
|
Pants: [new ChargenGearOption("Pants", 3u, 302u)],
|
|
Footwear: [new ChargenGearOption("Boots", 4u, 303u)],
|
|
ClothingColors: [400u, 401u, 402u]);
|
|
|
|
// Same shape as `gender`, just the other GenderKey — every list is
|
|
// deliberately populated so a full RandomizeCharacter roll never
|
|
// finds an empty option list to skip.
|
|
var femaleGender = gender with { GenderKey = (int)FemaleGenderKey, Name = "Female" };
|
|
var bothGenders = new Dictionary<int, ChargenGenderOptions>
|
|
{
|
|
[(int)MaleGenderKey] = gender,
|
|
[(int)FemaleGenderKey] = femaleGender,
|
|
};
|
|
|
|
var aluvianTemplates = new List<ChargenTemplate>
|
|
{
|
|
new(
|
|
"Custom",
|
|
IconId: 0u,
|
|
TitleStringId: 0u,
|
|
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
|
|
NormalSkills: [SkillCustomNormal],
|
|
PrimarySkills: []),
|
|
new(
|
|
"Preset",
|
|
IconId: 0u,
|
|
TitleStringId: 0u,
|
|
Attributes: new ChargenAttributeValues(16, 10, 10, 10, 10, 10),
|
|
NormalSkills: [SkillTrainSpecialize],
|
|
PrimarySkills: [SkillPresetPrimary]),
|
|
};
|
|
|
|
var aluvian = new ChargenHeritageOptions(
|
|
AluvianId,
|
|
"Aluvian",
|
|
IconId: 0u,
|
|
SetupId: 0x2000054u,
|
|
EnvironmentSetupId: 0u,
|
|
AttributeCredits: 66u,
|
|
SkillCredits: 50u,
|
|
PrimaryStartAreaIndices: [0, 1],
|
|
SecondaryStartAreaIndices: [],
|
|
SkillCostsBySkillId: skillCosts,
|
|
Templates: aluvianTemplates,
|
|
GendersByKey: bothGenders);
|
|
|
|
var olthoiTemplates = new List<ChargenTemplate>
|
|
{
|
|
new(
|
|
"Custom",
|
|
IconId: 0u,
|
|
TitleStringId: 0u,
|
|
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
|
|
NormalSkills: [],
|
|
PrimarySkills: []),
|
|
new(
|
|
"NeverChosen",
|
|
IconId: 0u,
|
|
TitleStringId: 0u,
|
|
Attributes: new ChargenAttributeValues(20, 20, 20, 20, 20, 20),
|
|
NormalSkills: [],
|
|
PrimarySkills: []),
|
|
};
|
|
|
|
var olthoi = new ChargenHeritageOptions(
|
|
OlthoiId,
|
|
"Olthoi",
|
|
IconId: 0u,
|
|
SetupId: 0x2000054u,
|
|
EnvironmentSetupId: 0u,
|
|
AttributeCredits: 60u,
|
|
SkillCredits: 0u,
|
|
PrimaryStartAreaIndices: [0],
|
|
SecondaryStartAreaIndices: [],
|
|
SkillCostsBySkillId: new Dictionary<uint, ChargenSkillCost>(),
|
|
Templates: olthoiTemplates,
|
|
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)MaleGenderKey] = gender });
|
|
|
|
// Campaign CC slice CC5: CharGenState::RandomizeCharacter @
|
|
// 0x005c6d80 rolls a heritage id uniformly in [1, hasToD?4:3] — the
|
|
// four HUMAN heritage groups (ChargenHeritageGroup.Aluvian..
|
|
// Viamontian). RandomizeCharacterLocked's tests need every one of
|
|
// those four ids resolvable, not just Aluvian, so the roll can never
|
|
// silently land on a missing heritage. Ids 2-4 mirror Aluvian's own
|
|
// shape (same gender/template data) — the roll target, not the
|
|
// template/skill-budget math, is what those tests exercise.
|
|
ChargenHeritageOptions MakeHumanHeritage(uint id, string name) => new(
|
|
id,
|
|
name,
|
|
IconId: 0u,
|
|
SetupId: 0x2000054u,
|
|
EnvironmentSetupId: 0u,
|
|
AttributeCredits: 66u,
|
|
SkillCredits: 50u,
|
|
PrimaryStartAreaIndices: [0, 1],
|
|
SecondaryStartAreaIndices: [],
|
|
SkillCostsBySkillId: skillCosts,
|
|
Templates: aluvianTemplates,
|
|
GendersByKey: bothGenders);
|
|
|
|
// A deliberately impoverished heritage — just enough skill credits
|
|
// to train SkillTrainSpecialize but never specialize it — so a
|
|
// TrySpecializeSkill affordability refusal is directly testable
|
|
// without needing to hand-drain the richer Aluvian budget.
|
|
var impoverished = new ChargenHeritageOptions(
|
|
ImpoverishedId,
|
|
"Impoverished",
|
|
IconId: 0u,
|
|
SetupId: 0x2000054u,
|
|
EnvironmentSetupId: 0u,
|
|
AttributeCredits: 60u,
|
|
SkillCredits: 5u,
|
|
PrimaryStartAreaIndices: [0],
|
|
SecondaryStartAreaIndices: [],
|
|
SkillCostsBySkillId: new Dictionary<uint, ChargenSkillCost>
|
|
{
|
|
[SkillTrainSpecialize] = new(SkillTrainSpecialize, NormalCost: 4, PrimaryCost: 12),
|
|
},
|
|
Templates:
|
|
[
|
|
new ChargenTemplate(
|
|
"Custom",
|
|
IconId: 0u,
|
|
TitleStringId: 0u,
|
|
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
|
|
NormalSkills: [],
|
|
PrimarySkills: []),
|
|
],
|
|
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)MaleGenderKey] = gender });
|
|
|
|
return new ChargenOptions(
|
|
starterAreas,
|
|
new Dictionary<uint, ChargenHeritageOptions>
|
|
{
|
|
[AluvianId] = aluvian,
|
|
[(uint)ChargenHeritageGroup.Gharundim] = MakeHumanHeritage(
|
|
(uint)ChargenHeritageGroup.Gharundim, "Gharu'ndim"),
|
|
[(uint)ChargenHeritageGroup.Sho] = MakeHumanHeritage(
|
|
(uint)ChargenHeritageGroup.Sho, "Sho"),
|
|
[(uint)ChargenHeritageGroup.Viamontian] = MakeHumanHeritage(
|
|
(uint)ChargenHeritageGroup.Viamontian, "Viamontian"),
|
|
[OlthoiId] = olthoi,
|
|
[ImpoverishedId] = impoverished,
|
|
},
|
|
new Dictionary<uint, ChargenSkillCost>());
|
|
}
|
|
}
|