feat(chargen): Campaign CC slice CC5 — Summary page, Finish flow, RandomizeCharacter port
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>
This commit is contained in:
parent
6114b2dda2
commit
34e3a534be
22 changed files with 2217 additions and 79 deletions
|
|
@ -21,6 +21,15 @@ internal static class RuntimeCharacterCreationStateFixture
|
|||
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>
|
||||
|
|
@ -99,6 +108,16 @@ internal static class RuntimeCharacterCreationStateFixture
|
|||
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(
|
||||
|
|
@ -129,7 +148,7 @@ internal static class RuntimeCharacterCreationStateFixture
|
|||
SecondaryStartAreaIndices: [],
|
||||
SkillCostsBySkillId: skillCosts,
|
||||
Templates: aluvianTemplates,
|
||||
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)MaleGenderKey] = gender });
|
||||
GendersByKey: bothGenders);
|
||||
|
||||
var olthoiTemplates = new List<ChargenTemplate>
|
||||
{
|
||||
|
|
@ -163,6 +182,28 @@ internal static class RuntimeCharacterCreationStateFixture
|
|||
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
|
||||
|
|
@ -198,6 +239,12 @@ internal static class RuntimeCharacterCreationStateFixture
|
|||
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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -555,6 +555,42 @@ public sealed class RuntimeCharacterCreationStateTests
|
|||
Assert.True(refusal.AlreadyPending);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F12 amendment (CC6b-MOUNT review fix round, filed as register
|
||||
/// AP-223): with AD-101 retired, a caller could otherwise reach Finish
|
||||
/// with heritage/gender still unset. Retail's own <c>DoFinish</c> never
|
||||
/// checks this because <c>RandomizeCharacter</c> guarantees it can't
|
||||
/// happen — this is acdream's own defensive backstop for any caller that
|
||||
/// bypasses the App layer's screen-open roll.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryBeginFinish_HeritageUnset_IsRefused()
|
||||
{
|
||||
RuntimeCharacterCreationState state = CreateActive();
|
||||
state.TrySetName("Adventurer");
|
||||
|
||||
bool accepted = state.TryBeginFinish(
|
||||
0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal);
|
||||
|
||||
Assert.False(accepted);
|
||||
Assert.True(refusal.HeritageOrGenderUnset);
|
||||
Assert.False(refusal.NoName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBeginFinish_GenderUnset_IsRefused()
|
||||
{
|
||||
RuntimeCharacterCreationState state = CreateActive();
|
||||
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||
state.TrySetName("Adventurer");
|
||||
|
||||
bool accepted = state.TryBeginFinish(
|
||||
0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal);
|
||||
|
||||
Assert.False(accepted);
|
||||
Assert.True(refusal.HeritageOrGenderUnset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBeginFinish_RosterAtSlotCap_IsRefused()
|
||||
{
|
||||
|
|
@ -700,4 +736,162 @@ public sealed class RuntimeCharacterCreationStateTests
|
|||
|
||||
Assert.Null(state.Snapshot.LastRejection);
|
||||
}
|
||||
|
||||
// ── Randomize (Campaign CC slice CC5, RandomizeCharacter port) ───────
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>CharGenState::RandomizeCharacter @ 0x005c6d80</c>: rolls a
|
||||
/// heritage id in [1,4] (the four HUMAN groups, never the other nine),
|
||||
/// a gender in [1,2], and freezes both non-Unset. A 200-iteration sweep
|
||||
/// with a fresh seeded RNG per iteration proves the heritage roll never
|
||||
/// escapes the 1-4 human-only range even though the fixture ALSO
|
||||
/// carries a non-human Olthoi heritage (id 12) and an out-of-range
|
||||
/// "Impoverished" heritage (id 90) that a broken roll could otherwise
|
||||
/// land on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryRandomizeCharacter_RollsOnlyTheFourHumanHeritagesAndAGender()
|
||||
{
|
||||
for (int seed = 0; seed < 200; seed++)
|
||||
{
|
||||
var state = new RuntimeCharacterCreationState(
|
||||
RuntimeCharacterCreationStateFixture.Build(),
|
||||
new Random(seed));
|
||||
state.Begin(new RuntimeGenerationToken(1));
|
||||
|
||||
Assert.True(state.TryRandomizeCharacter());
|
||||
|
||||
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
|
||||
Assert.InRange(snapshot.HeritageId, 1u, 4u);
|
||||
Assert.True(snapshot.GenderKey is 1u or 2u);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRandomizeCharacter_RollsAppearanceClothingTemplateAndStartArea()
|
||||
{
|
||||
var state = new RuntimeCharacterCreationState(
|
||||
RuntimeCharacterCreationStateFixture.Build(),
|
||||
new Random(7));
|
||||
state.Begin(new RuntimeGenerationToken(1));
|
||||
|
||||
Assert.True(state.TryRandomizeCharacter());
|
||||
|
||||
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
|
||||
// Every list in the fixture's shared gender record is non-empty, so
|
||||
// a full randomize must leave nothing Unset.
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.EyesStrip);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairColor);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.ShirtStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.TrousersStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.FootwearStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template);
|
||||
// Template is one of the PRESET rows (never index 0/Custom) —
|
||||
// RandomizeTemplate @ 0x005c6500's RandInt(count-1,...)+1 shape.
|
||||
Assert.NotEqual(0u, snapshot.Template);
|
||||
Assert.True(snapshot.StartArea is 0 or 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>RandomizeTemplateLocked</c>'s own Olthoi/OlthoiAcid branch
|
||||
/// (mirroring <c>CharGenState::RandomizeTemplate</c>'s force-to-template-0
|
||||
/// arm) is UNREACHABLE through <see cref="RuntimeCharacterCreationState.TryRandomizeCharacter"/>
|
||||
/// specifically — that caller's own heritage roll is always one of the
|
||||
/// four HUMAN ids (never Olthoi), matching retail's identical
|
||||
/// architecture (<c>RandomizeCharacter</c>'s heritage roll and
|
||||
/// <c>RandomizeTemplate</c>'s Olthoi branch are independent call paths;
|
||||
/// retail never composes them either, since a random CHARACTER is never
|
||||
/// Olthoi). The branch is not otherwise exposed as a standalone command
|
||||
/// this slice (out of CC5's named scope), so its OBSERVABLE behavior —
|
||||
/// selecting Olthoi always forces template 0 — is already covered by
|
||||
/// <c>TrySelectHeritage_Olthoi...</c>/<c>ApplyTemplate</c> coverage
|
||||
/// elsewhere in this file; this test only pins that a full
|
||||
/// <c>TryRandomizeCharacter</c> roll never lands on Olthoi in the first
|
||||
/// place, over enough iterations to catch a boundary-off-by-one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryRandomizeCharacter_NeverRollsANonHumanHeritage()
|
||||
{
|
||||
for (int seed = 0; seed < 200; seed++)
|
||||
{
|
||||
var state = new RuntimeCharacterCreationState(
|
||||
RuntimeCharacterCreationStateFixture.Build(),
|
||||
new Random(seed));
|
||||
state.Begin(new RuntimeGenerationToken(1));
|
||||
|
||||
Assert.True(state.TryRandomizeCharacter());
|
||||
|
||||
Assert.NotEqual(RuntimeCharacterCreationStateFixture.OlthoiId, state.Snapshot.HeritageId);
|
||||
Assert.NotEqual(RuntimeCharacterCreationStateFixture.ImpoverishedId, state.Snapshot.HeritageId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRandomizeCharacter_Inactive_IsRejected()
|
||||
{
|
||||
var state = new RuntimeCharacterCreationState(
|
||||
RuntimeCharacterCreationStateFixture.Build());
|
||||
Assert.False(state.TryRandomizeCharacter());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRandomizeAppearance_RequiresHeritageAndGender()
|
||||
{
|
||||
RuntimeCharacterCreationState state = CreateActive();
|
||||
Assert.False(state.TryRandomizeAppearance());
|
||||
|
||||
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||
Assert.False(state.TryRandomizeAppearance());
|
||||
|
||||
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||
Assert.True(state.TryRandomizeAppearance());
|
||||
Assert.NotEqual(
|
||||
RuntimeCharacterCreationAppearance.Unset,
|
||||
state.Snapshot.Appearance.HairStyle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRandomizeClothing_RollsAllFourGearSlots()
|
||||
{
|
||||
RuntimeCharacterCreationState state = CreateActive();
|
||||
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||
|
||||
Assert.True(state.TryRandomizeClothing());
|
||||
|
||||
RuntimeCharacterCreationAppearance a = state.Snapshot.Appearance;
|
||||
// Headgear excludes-current with the +1 Unset-ring reindex — the
|
||||
// fixture's single headgear style means the roll can only land on
|
||||
// style 0 or Unset; either is a valid outcome of the ring, so this
|
||||
// just confirms the call actually touched the field (shirt/trousers/
|
||||
// footwear below have no Unset ring and must land on their one
|
||||
// style).
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.ShirtStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.TrousersStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.FootwearStyle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>RandInt(int,int) @ 0x00684420</c>'s own decompiled shape: re-roll
|
||||
/// until the result differs from the excluded value, UNLESS there is
|
||||
/// only one possible outcome (<paramref name="count"/> <= 1), which
|
||||
/// returns 0 immediately without ever comparing against exclude (the
|
||||
/// guard that keeps the loop from spinning forever). This drives
|
||||
/// <see cref="RuntimeCharacterCreationState.TryRandomizeClothing"/>
|
||||
/// enough times to statistically prove the shirt slot (the fixture's
|
||||
/// single-style list) never gets stuck — count<=1 must short-circuit.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryRandomizeClothing_SingleOptionList_NeverHangs()
|
||||
{
|
||||
RuntimeCharacterCreationState state = CreateActive();
|
||||
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
Assert.True(state.TryRandomizeClothing());
|
||||
|
||||
Assert.Equal(0u, state.Snapshot.Appearance.ShirtStyle);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue