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
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue