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();
}