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:
Erik 2026-08-16 00:01:07 +02:00
parent 6114b2dda2
commit 34e3a534be
22 changed files with 2217 additions and 79 deletions

View file

@ -122,7 +122,8 @@ public sealed class CharacterCreationLiveDatTests
CharacterCreationUiController? controller =
CharacterCreationUiController.CreateDetached(
host, screen, ResolveTemplate, dialogs, bindings,
new CharacterCreationUiController.DialogStrings("Are you sure?"));
new CharacterCreationUiController.DialogStrings(
"Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long"));
Assert.NotNull(controller);
controller!.AttachAndTick();
controller.Dispose();
@ -515,6 +516,62 @@ public sealed class CharacterCreationLiveDatTests
return new RetailDialogFactory(host, CreateLayout);
}
/// <summary>
/// Campaign CC slice CC5 — the Summary page's full authored widget
/// catalog: the name field (with <c>NameInputFilter</c>), the how-to
/// text, the viewport (Summary's OWN <c>gmCG3DView</c>), and the
/// listbox's THREE row templates (single-line, category-header,
/// key/value pair) confirmed against the installed EoR dat — see
/// <see cref="CharacterCreationSummaryPage"/>'s own class doc for the
/// decomp citation (<c>SetSummaryText @ 0x0047b1d0</c>) each template
/// maps to.
/// </summary>
[InstalledDatFact]
public void SummaryPage_HasNameFieldListboxTemplatesAndViewport()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement summaryRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.SummaryPageElementId));
Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ScrollId));
Assert.IsType<UiField>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.NameTextId));
Assert.IsType<UiText>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.HowToTextId));
Assert.IsType<UiViewport>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ViewportId));
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ListBoxId));
Assert.Equal(3, list.Templates.Count);
UiElement? ResolveRow(int index) =>
LayoutImporter.Import(
dats,
list.Templates[index].TemplateLayoutId,
list.Templates[index].TemplateElementId,
_ => (0u, 0, 0),
null)?.Root;
UiElement lineRow = Assert.IsAssignableFrom<UiElement>(ResolveRow(0));
Assert.IsType<UiText>(UiElement.FindDescendant(lineRow, 0x100002F9u));
UiElement headerRow = Assert.IsAssignableFrom<UiElement>(ResolveRow(1));
Assert.IsType<UiText>(UiElement.FindDescendant(headerRow, 0x100000FEu));
UiElement pairRow = Assert.IsAssignableFrom<UiElement>(ResolveRow(2));
Assert.IsType<UiText>(UiElement.FindDescendant(pairRow, 0x100002FCu));
Assert.IsType<UiText>(UiElement.FindDescendant(pairRow, 0x100002FDu));
}
private static void AssertButton(ImportedLayout layout, uint elementId) =>
Assert.IsType<UiButton>(layout.FindElement(elementId));

View file

@ -2,6 +2,7 @@ using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.CharGen;
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.Runtime.Session;
@ -93,18 +94,32 @@ public sealed class CharacterCreationUiControllerTests
CharacterCreationUiController.SummaryPageElementId).Visible);
}
/// <summary>Campaign CC slice CC5: Finish (<c>0x100003c8</c>) is
/// ghosted everywhere EXCEPT Summary — retail's own
/// <c>ListenToElementMessage</c> case only sends when
/// <c>m_eProgressState == ECG_SUMMARY</c>; off Summary the button now
/// has a real <c>OnClick</c> handler (<c>OnFinish</c>, which itself
/// re-checks the current page) but stays disabled.</summary>
[Fact]
public void Finish_StaysGhosted_NoOnClickHandler()
public void Finish_GhostedExceptOnSummary()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
UiButton finish = environment.Button(CharacterCreationUiController.FinishElementId);
Assert.Null(finish.OnClick);
Assert.NotNull(finish.OnClick);
Assert.False(finish.Enabled);
environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!();
Assert.True(finish.Enabled);
}
/// <summary>Campaign CC slice CC5: Random (<c>0x100003cb</c>) is now
/// enabled on Appearance and Summary too — CC5 ports
/// RandomizeAppearance/RandomizeClothing/RandomizeCharacter, retiring
/// both gaps AP-212 used to track. Only Skills' unported
/// RandomizeSkills keeps Random disabled.</summary>
[Fact]
public void Random_IsDisabledOnSkillsAppearanceAndSummaryPages()
public void Random_IsDisabledOnSkillsPageOnly()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
@ -115,10 +130,10 @@ public sealed class CharacterCreationUiControllerTests
Assert.False(random.Enabled);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
Assert.False(random.Enabled);
Assert.True(random.Enabled);
environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!();
Assert.False(random.Enabled);
Assert.True(random.Enabled);
environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!();
Assert.True(random.Enabled);
@ -791,6 +806,304 @@ public sealed class CharacterCreationUiControllerTests
environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!();
}
// ── CC5: RandomizeCharacter open-roll + gender flip ─────────────────
/// <summary>Ports <c>gmCharGenMainUI</c>'s ctor-time roll +
/// <c>gmCGAppearancePage::InitializePage</c>'s gender-flip
/// (<c>~0x004802da-0x00480303</c>) — retiring AP-214's honest-blank
/// deviation. <see cref="FakeRuntime.RandomizeCharacter"/> lands
/// deterministically on <see cref="FakeRuntime.RandomizedHeritageId"/>/
/// <see cref="FakeRuntime.RandomizedGenderKey"/> so the flip assertion
/// isn't flaky.</summary>
[Fact]
public void Open_RollsACharacterThenFlipsTheGenderToTheOpposite()
{
using var environment = new EnvironmentHarness();
environment.Runtime.RandomizedHeritageId = AluvianId;
environment.Runtime.RandomizedGenderKey = GenderKey; // Male = 1
environment.Controller.Open();
Assert.Equal(1, environment.Runtime.RandomizeCharacterCalls);
Assert.Equal(AluvianId, environment.Runtime.View.Snapshot.HeritageId);
// RandomizeCharacter rolled Male (1); InitializePage's own flip
// immediately inverts it to Female (2).
Assert.Equal(2u, environment.Runtime.LastSelectedGender);
Assert.Equal(2u, environment.Runtime.View.Snapshot.GenderKey);
}
[Fact]
public void Open_RandomizeCharacterRejected_DoesNotAttemptTheGenderFlip()
{
using var environment = new EnvironmentHarness();
environment.Runtime.RandomizeCharacterAccepts = false;
environment.Controller.Open();
Assert.Equal(0u, environment.Runtime.LastSelectedGender);
}
// ── CC5: Finish (DoFinish @ 0x004E9170) ──────────────────────────────
private static void GoToSummary(EnvironmentHarness environment) =>
environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!();
[Fact]
public void Finish_EmptyName_ShowsNoNameWarningDialog_AndDoesNotSend()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
environment.Button(CharacterCreationUiController.FinishElementId).OnClick!();
Assert.Equal(1, environment.Runtime.FinishCallCount);
Assert.True(environment.Dialogs.IsOpen);
Assert.Equal("No name entered.", environment.LastDialogMessage());
}
[Fact]
public void Finish_UnspentCredits_ShowsCreditWarning_ConfirmResendsWithConfirmedFlag()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with
{
Name = "Adventurer",
RemainingAttributeCredits = 6,
};
GoToSummary(environment);
environment.Button(CharacterCreationUiController.FinishElementId).OnClick!();
Assert.Equal(1, environment.Runtime.FinishCallCount);
Assert.False(environment.Runtime.LastConfirmedUnspentCredits);
Assert.True(environment.Dialogs.IsOpen);
Assert.Equal("You have unspent attribute credits.", environment.LastDialogMessage());
environment.ConfirmActiveDialog(confirmed: true);
Assert.Equal(2, environment.Runtime.FinishCallCount);
Assert.True(environment.Runtime.LastConfirmedUnspentCredits);
}
[Fact]
public void Finish_UnspentCredits_CancelDialog_DoesNotResend()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with
{
Name = "Adventurer",
RemainingAttributeCredits = 6,
};
GoToSummary(environment);
environment.Button(CharacterCreationUiController.FinishElementId).OnClick!();
environment.ConfirmActiveDialog(confirmed: false);
Assert.Equal(1, environment.Runtime.FinishCallCount);
}
[Fact]
public void Finish_Accepted_ShowsNoDialog()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with
{
Name = "Adventurer",
RemainingAttributeCredits = 0,
};
GoToSummary(environment);
environment.Button(CharacterCreationUiController.FinishElementId).OnClick!();
Assert.Equal(1, environment.Runtime.FinishCallCount);
Assert.False(environment.Dialogs.IsOpen);
}
[Fact]
public void Finish_OffSummaryPage_IsANoOp()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open(); // defaults to Heritage
environment.Button(CharacterCreationUiController.FinishElementId).OnClick!();
Assert.Equal(0, environment.Runtime.FinishCallCount);
}
// ── CC5: Random on Summary (MakeRandomizeWarningDialog @ 0x004e8a90) ─
[Fact]
public void RandomOnSummary_ShowsWarningDialog_ConfirmCallsRandomizeCharacter()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
environment.Button(CharacterCreationUiController.RandomElementId).OnClick!();
Assert.True(environment.Dialogs.IsOpen);
Assert.Equal("This will randomize your character.", environment.LastDialogMessage());
int callsBeforeConfirm = environment.Runtime.RandomizeCharacterCalls;
environment.ConfirmActiveDialog(confirmed: true);
Assert.Equal(callsBeforeConfirm + 1, environment.Runtime.RandomizeCharacterCalls);
}
[Fact]
public void RandomOnSummary_CancelDialog_DoesNotRandomize()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
int callsBeforeClick = environment.Runtime.RandomizeCharacterCalls;
environment.Button(CharacterCreationUiController.RandomElementId).OnClick!();
environment.ConfirmActiveDialog(confirmed: false);
Assert.Equal(callsBeforeClick, environment.Runtime.RandomizeCharacterCalls);
}
// ── CC5: Random on Appearance (DoRandom case 3) ──────────────────────
[Fact]
public void RandomOnAppearance_FaceSubTab_CallsRandomizeAppearance()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
environment.Button(CharacterCreationUiController.RandomElementId).OnClick!();
Assert.Equal(1, environment.Runtime.RandomizeAppearanceCalls);
Assert.Equal(0, environment.Runtime.RandomizeClothingCalls);
}
[Fact]
public void RandomOnAppearance_ClothesSubTab_CallsRandomizeClothing()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
environment.Button(CharacterCreationAppearancePage.ClothesButtonId).OnClick!();
environment.Button(CharacterCreationUiController.RandomElementId).OnClick!();
Assert.Equal(1, environment.Runtime.RandomizeClothingCalls);
Assert.Equal(0, environment.Runtime.RandomizeAppearanceCalls);
}
// ── CC5: Summary name field (ListenToElementMessage @ 0x0047bf40) ────
[Fact]
public void SummaryNameField_Submit_CommitsTheName()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
UiField field = environment.SummaryNameField();
field.SetText("Adventurer");
field.Submit();
Assert.Equal("Adventurer", environment.Runtime.LastSetName);
}
[Fact]
public void SummaryNameField_TooLong_ShowsDialogAndRevertsToLastCommitted()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
UiField field = environment.SummaryNameField();
field.SetText("Adventurer");
field.Submit();
Assert.Equal("Adventurer", environment.Runtime.LastSetName);
field.SetText(new string('a', 40));
field.Submit();
// The over-limit text never reached SetName, and the too-long
// dialog fired with the field reverted to the last commit.
Assert.Equal("Adventurer", environment.Runtime.LastSetName);
Assert.True(environment.Dialogs.IsOpen);
Assert.Equal("That name is too long.", environment.LastDialogMessage());
Assert.Equal("Adventurer", field.Text);
}
[Fact]
public void SummaryNameField_NameInputFilter_RejectsDigitsAndSymbols()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
UiField field = environment.SummaryNameField();
Assert.NotNull(field.CharacterFilter);
Assert.True(field.CharacterFilter!('A'));
Assert.True(field.CharacterFilter!(' '));
Assert.True(field.CharacterFilter!('\''));
Assert.True(field.CharacterFilter!('-'));
Assert.False(field.CharacterFilter!('7'));
Assert.False(field.CharacterFilter!('$'));
}
// ── CC5: 0xF643 rejection dialogs ─────────────────────────────────────
[Fact]
public void CreationFailed_NameInUse_ShowsTheRetailErrorDialog_AndAcknowledgesOnClose()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_Character_Err_NameReserved"] = "That name is in use.";
environment.Controller.Open();
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with
{
LastRejection = new RuntimeCharacterCreationRejection(
3u,
CharGenVerificationResponse.Code.NameInUse,
"NameInUse",
"Adventurer"),
};
BumpRevisionAndTick(environment);
Assert.True(environment.Dialogs.IsOpen);
Assert.Equal("That name is in use.", environment.LastDialogMessage());
environment.DismissActiveMessageDialog();
Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls);
}
[Fact]
public void CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_Character_Err_NameBanned"] = "That name is banned.";
environment.Controller.Open();
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with
{
LastRejection = new RuntimeCharacterCreationRejection(
4u, CharGenVerificationResponse.Code.NameBanned, "NameBanned", "Adventurer"),
};
BumpRevisionAndTick(environment);
Assert.Equal(1, environment.Dialogs.ActiveCount);
// A second Tick with the SAME rejection instance (no revision bump,
// no new value) must not reopen the dialog — ReconcileDialogs runs
// every Tick, not just on revision change.
environment.Controller.Tick();
Assert.Equal(1, environment.Dialogs.ActiveCount);
}
private sealed class FakeChargenPreviewControl : AcDream.App.Rendering.IChargenPreviewControl
{
public int ZoomInCalls { get; private set; }
@ -850,7 +1163,11 @@ public sealed class CharacterCreationUiControllerTests
Dialogs,
Runtime.Bindings,
new CharacterCreationUiController.DialogStrings(
"Are you sure you want to leave?")));
"Are you sure you want to leave?",
"No name entered.",
"You have unspent attribute credits.",
"This will randomize your character.",
"That name is too long.")));
Controller.AttachAndTick();
}
@ -874,6 +1191,9 @@ public sealed class CharacterCreationUiControllerTests
public UiScrollbar ShadeScroll() =>
Assert.IsType<UiScrollbar>(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId));
public UiField SummaryNameField() =>
Assert.IsType<UiField>(Screen.FindElement(CharacterCreationSummaryPage.NameTextId));
/// <summary>Confirms or cancels the MOST RECENTLY opened confirmation
/// dialog, using <see cref="RetailConfirmationDialogView"/>'s real
/// button ids off the layout the factory's <c>createLayout</c>
@ -889,10 +1209,39 @@ public sealed class CharacterCreationUiControllerTests
button.OnClick!();
}
/// <summary>Same shape as <see cref="ConfirmActiveDialog"/>, for a
/// plain informational (<see cref="RetailMessageDialogView"/>) OK
/// dialog — <see cref="ShowNoNameWarningDialog"/>/the 0xF643
/// rejection dialogs use this shape, not confirm/cancel.</summary>
public void DismissActiveMessageDialog()
{
ImportedLayout dialog = _dialogLayouts[^1];
UiButton button = Assert.IsType<UiButton>(
dialog.FindElement(RetailMessageDialogView.OkButtonId));
button.OnClick!();
}
/// <summary>The MOST RECENTLY opened dialog's message text —
/// mirrors <c>CharacterManagementUiControllerTests.Message</c>'s
/// own lookup shape (element id <c>0x3E</c>, every
/// <see cref="RetailDialogFactoryTests.BuildDialogLayout"/> popup's
/// text child).</summary>
public string LastDialogMessage() => string.Join(
" ",
Assert.IsType<UiText>(_dialogLayouts[^1].FindElement(0x3Eu))
.LinesProvider()
.Select(static line => line.Text));
private static UiElement? ResolveSkillRowTemplate(
uint templateLayoutId,
uint templateElementId) =>
BuildSkillRowTemplate(templateElementId);
templateElementId switch
{
SummaryLineTemplateId => BuildSummaryLineTemplate(),
SummaryHeaderTemplateId => BuildSummaryHeaderTemplate(),
SummaryPairTemplateId => BuildSummaryPairTemplate(),
_ => BuildSkillRowTemplate(templateElementId),
};
public void Dispose()
{
@ -919,11 +1268,16 @@ public sealed class CharacterCreationUiControllerTests
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Specialized),
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Untrained),
SelectStartArea,
_ => Result(RuntimeCommandStatus.Accepted),
Finish,
() => RequestExitCalls++,
SetAppearanceIndex: SetAppearanceIndex,
SetShade: SetShade,
ResolveText: _ => null,
ResolveText: key => ResolvedStrings.TryGetValue(key, out string? value) ? value : null,
SetName: SetName,
AcknowledgeRejection: AcknowledgeRejection,
RandomizeCharacter: RandomizeCharacter,
RandomizeAppearance: () => { RandomizeAppearanceCalls++; return Result(RuntimeCommandStatus.Accepted); },
RandomizeClothing: () => { RandomizeClothingCalls++; return Result(RuntimeCommandStatus.Accepted); },
OpenOnStart: false);
}
@ -942,6 +1296,36 @@ public sealed class CharacterCreationUiControllerTests
public int AppearanceIndexCallCount { get; private set; }
public ChargenShadeSlot? LastShadeSlot { get; private set; }
public double LastShadeValue { get; private set; }
public string? LastSetName { get; private set; }
public int FinishCallCount { get; private set; }
public bool LastConfirmedUnspentCredits { get; private set; }
public int AcknowledgeRejectionCalls { get; private set; }
public int RandomizeCharacterCalls { get; private set; }
public int RandomizeAppearanceCalls { get; private set; }
public int RandomizeClothingCalls { get; private set; }
/// <summary>
/// The exact HeritageId/GenderKey a fake RandomizeCharacter roll
/// lands on — deterministic (not random) so tests can assert the
/// flip-to-opposite-gender behavior precisely. Defaults to 0/0 (a
/// no-op "roll") so the 37 PRE-EXISTING <c>Open()</c> call sites in
/// this file — written against the honest-blank-open contract
/// AP-214 tracked before this slice — keep observing a blank
/// heritage/gender after <c>Open()</c> without every one of them
/// having to opt out individually; only the tests THIS slice adds
/// that specifically exercise the roll set these explicitly.
/// </summary>
public uint RandomizedHeritageId { get; set; }
public uint RandomizedGenderKey { get; set; }
/// <summary>Lets a test simulate the Runtime-inactive/rejected case
/// (e.g. a session already gone) without needing a real
/// <c>RuntimeCharacterCreationState</c>.</summary>
public bool RandomizeCharacterAccepts { get; set; } = true;
/// <summary>Populated by tests exercising the <c>ID_Character_Err_*</c>
/// rejection-dialog path — <c>ResolveText</c> above reads from it.</summary>
public Dictionary<string, string> ResolvedStrings { get; } = [];
public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId);
@ -1007,6 +1391,65 @@ public sealed class CharacterCreationUiControllerTests
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SetName(string name)
{
LastSetName = name;
View.Snapshot = View.Snapshot with { Name = name };
return Result(RuntimeCommandStatus.Accepted);
}
/// <summary>Mirrors <c>RuntimeCharacterCreationState.TryBeginFinish</c>'s
/// refusal-priority chain closely enough to drive
/// <c>CharacterCreationUiController.TryFinish</c>'s own dialog
/// dispatch under test — NoName, then HeritageOrGenderUnset, then
/// (unless confirmed) AttributeCreditsUnspent, else Accepted.</summary>
private RuntimeCommandResult Finish(bool confirmUnspentCredits)
{
FinishCallCount++;
LastConfirmedUnspentCredits = confirmUnspentCredits;
RuntimeCharacterCreationSnapshot snapshot = View.Snapshot;
string trimmed = snapshot.Name.Trim();
RuntimeCharacterCreationLocalRefusal refusal = trimmed.Length == 0
? new RuntimeCharacterCreationLocalRefusal(NoName: true, false, false, false)
: snapshot.HeritageId == 0u || snapshot.GenderKey == 0u
? new RuntimeCharacterCreationLocalRefusal(
false, false, false, false, HeritageOrGenderUnset: true)
: !confirmUnspentCredits && snapshot.RemainingAttributeCredits > 0
? new RuntimeCharacterCreationLocalRefusal(
false, AttributeCreditsUnspent: true, false, false)
: RuntimeCharacterCreationLocalRefusal.None;
View.Snapshot = snapshot with { Name = trimmed, LastLocalRefusal = refusal };
return Result(refusal.Any ? RuntimeCommandStatus.Rejected : RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult AcknowledgeRejection()
{
AcknowledgeRejectionCalls++;
View.Snapshot = View.Snapshot with { LastRejection = null };
return Result(RuntimeCommandStatus.Accepted);
}
/// <summary>Deterministic fake for
/// <c>RuntimeCharacterCreationState.TryRandomizeCharacter</c> — real
/// randomness would make the gender-flip assertion in
/// <c>Open_RollsARandomCharacterThenFlipsAppearancePageGender</c>
/// flaky, so this always lands on <see cref="RandomizedHeritageId"/>/
/// <see cref="RandomizedGenderKey"/> instead.</summary>
private RuntimeCommandResult RandomizeCharacter()
{
RandomizeCharacterCalls++;
if (!RandomizeCharacterAccepts)
return Result(RuntimeCommandStatus.Rejected);
View.Snapshot = View.Snapshot with
{
HeritageId = RandomizedHeritageId,
GenderKey = RandomizedGenderKey,
};
return Result(RuntimeCommandStatus.Accepted);
}
private static RuntimeCharacterCreationAppearance WithAppearanceIndex(
RuntimeCharacterCreationAppearance a,
ChargenAppearanceSlot slot,
@ -1227,7 +1670,7 @@ public sealed class CharacterCreationUiControllerTests
root.Children.Add(BuildSkillsPage());
root.Children.Add(BuildAppearancePage());
root.Children.Add(BuildTownPage());
root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId));
root.Children.Add(BuildSummaryPage());
root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId));
@ -1419,6 +1862,77 @@ public sealed class CharacterCreationUiControllerTests
_ => (0u, 0, 0),
null).Root;
// ── Summary page fixture (CC5) ───────────────────────────────────────
// Template element ids match the LIVE-DAT-probe-confirmed retail ones
// (CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport)
// for readability, though this hand-built fixture doesn't require it.
private const uint SummaryLineTemplateId = 0x100002F8u;
private const uint SummaryHeaderTemplateId = 0x100002FAu;
private const uint SummaryPairTemplateId = 0x100002FBu;
private static ElementInfo BuildSummaryPage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.SummaryPageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
var list = new ElementInfo
{
Id = CharacterCreationSummaryPage.ListBoxId,
Type = 5u,
X = 20f,
Y = 40f,
Width = 400f,
Height = 300f,
};
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryLineTemplateId));
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryHeaderTemplateId));
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryPairTemplateId));
page.Children.Add(list);
page.Children.Add(ScrollbarInfo(CharacterCreationSummaryPage.ScrollId));
page.Children.Add(EditableFieldInfo(CharacterCreationSummaryPage.NameTextId));
page.Children.Add(TextInfo(CharacterCreationSummaryPage.HowToTextId));
var viewport = new ElementInfo
{
Id = CharacterCreationSummaryPage.ViewportId,
Type = 0xDu,
Width = 300f,
Height = 300f,
};
page.Children.Add(viewport);
return page;
}
private static UiElement BuildSummaryLineTemplate()
{
var root = new ElementInfo { Id = 0x90001u, Type = 3u, Width = 380f, Height = 16f };
root.Children.Add(TextInfo(0x100002F9u));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root;
}
private static UiElement BuildSummaryHeaderTemplate()
{
var root = new ElementInfo { Id = 0x90002u, Type = 3u, Width = 380f, Height = 18f };
root.Children.Add(TextInfo(0x100000FEu));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root;
}
private static UiElement BuildSummaryPairTemplate()
{
var root = new ElementInfo { Id = 0x90003u, Type = 3u, Width = 380f, Height = 16f };
root.Children.Add(TextInfo(0x100002FCu));
root.Children.Add(TextInfo(0x100002FDu));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root;
}
private static ElementInfo ContainerInfo(uint id) => new()
{
Id = id,

View file

@ -290,7 +290,8 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
_dialogs,
Runtime.Bindings,
new CharacterCreationUiController.DialogStrings(
"Are you sure you want to leave?")));
"Are you sure you want to leave?",
"No name", "Unspent credits", "Randomize?", "Name too long")));
Controller.AttachAndTick();
}

View file

@ -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,
},

View file

@ -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"/> &lt;= 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&lt;=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);
}
}