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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ internal static class RuntimeCharacterCreationStateFixture
|
|||
public const uint ImpoverishedId = 90u;
|
||||
public const uint MaleGenderKey = 1u;
|
||||
|
||||
/// <summary>Campaign CC slice CC5: <c>RandomizeCharacter</c>'s gender
|
||||
/// roll (<c>RollDice(1,2)</c>) needs BOTH gender keys resolvable on the
|
||||
/// four human heritages, or half of all random seeds would land on a
|
||||
/// gender <see cref="ChargenHeritageOptions.GendersByKey"/> can't
|
||||
/// resolve and silently leave appearance untouched (a real, harmless
|
||||
/// <c>ConstrainAllByGender</c>-style fallback — but not what these tests
|
||||
/// are pinning).</summary>
|
||||
public const uint FemaleGenderKey = 2u;
|
||||
|
||||
/// <summary>str=10 end=10 coord=10 quick=10 focus=10 self=10 — the
|
||||
/// budget-66 heritage leaves 6 credits unspent after this template,
|
||||
/// matching retail's "Custom sits at the floor" finding.</summary>
|
||||
|
|
@ -99,6 +108,16 @@ internal static class RuntimeCharacterCreationStateFixture
|
|||
Footwear: [new ChargenGearOption("Boots", 4u, 303u)],
|
||||
ClothingColors: [400u, 401u, 402u]);
|
||||
|
||||
// Same shape as `gender`, just the other GenderKey — every list is
|
||||
// deliberately populated so a full RandomizeCharacter roll never
|
||||
// finds an empty option list to skip.
|
||||
var femaleGender = gender with { GenderKey = (int)FemaleGenderKey, Name = "Female" };
|
||||
var bothGenders = new Dictionary<int, ChargenGenderOptions>
|
||||
{
|
||||
[(int)MaleGenderKey] = gender,
|
||||
[(int)FemaleGenderKey] = femaleGender,
|
||||
};
|
||||
|
||||
var aluvianTemplates = new List<ChargenTemplate>
|
||||
{
|
||||
new(
|
||||
|
|
@ -129,7 +148,7 @@ internal static class RuntimeCharacterCreationStateFixture
|
|||
SecondaryStartAreaIndices: [],
|
||||
SkillCostsBySkillId: skillCosts,
|
||||
Templates: aluvianTemplates,
|
||||
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)MaleGenderKey] = gender });
|
||||
GendersByKey: bothGenders);
|
||||
|
||||
var olthoiTemplates = new List<ChargenTemplate>
|
||||
{
|
||||
|
|
@ -163,6 +182,28 @@ internal static class RuntimeCharacterCreationStateFixture
|
|||
Templates: olthoiTemplates,
|
||||
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)MaleGenderKey] = gender });
|
||||
|
||||
// Campaign CC slice CC5: CharGenState::RandomizeCharacter @
|
||||
// 0x005c6d80 rolls a heritage id uniformly in [1, hasToD?4:3] — the
|
||||
// four HUMAN heritage groups (ChargenHeritageGroup.Aluvian..
|
||||
// Viamontian). RandomizeCharacterLocked's tests need every one of
|
||||
// those four ids resolvable, not just Aluvian, so the roll can never
|
||||
// silently land on a missing heritage. Ids 2-4 mirror Aluvian's own
|
||||
// shape (same gender/template data) — the roll target, not the
|
||||
// template/skill-budget math, is what those tests exercise.
|
||||
ChargenHeritageOptions MakeHumanHeritage(uint id, string name) => new(
|
||||
id,
|
||||
name,
|
||||
IconId: 0u,
|
||||
SetupId: 0x2000054u,
|
||||
EnvironmentSetupId: 0u,
|
||||
AttributeCredits: 66u,
|
||||
SkillCredits: 50u,
|
||||
PrimaryStartAreaIndices: [0, 1],
|
||||
SecondaryStartAreaIndices: [],
|
||||
SkillCostsBySkillId: skillCosts,
|
||||
Templates: aluvianTemplates,
|
||||
GendersByKey: bothGenders);
|
||||
|
||||
// A deliberately impoverished heritage — just enough skill credits
|
||||
// to train SkillTrainSpecialize but never specialize it — so a
|
||||
// TrySpecializeSkill affordability refusal is directly testable
|
||||
|
|
@ -198,6 +239,12 @@ internal static class RuntimeCharacterCreationStateFixture
|
|||
new Dictionary<uint, ChargenHeritageOptions>
|
||||
{
|
||||
[AluvianId] = aluvian,
|
||||
[(uint)ChargenHeritageGroup.Gharundim] = MakeHumanHeritage(
|
||||
(uint)ChargenHeritageGroup.Gharundim, "Gharu'ndim"),
|
||||
[(uint)ChargenHeritageGroup.Sho] = MakeHumanHeritage(
|
||||
(uint)ChargenHeritageGroup.Sho, "Sho"),
|
||||
[(uint)ChargenHeritageGroup.Viamontian] = MakeHumanHeritage(
|
||||
(uint)ChargenHeritageGroup.Viamontian, "Viamontian"),
|
||||
[OlthoiId] = olthoi,
|
||||
[ImpoverishedId] = impoverished,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -555,6 +555,42 @@ public sealed class RuntimeCharacterCreationStateTests
|
|||
Assert.True(refusal.AlreadyPending);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F12 amendment (CC6b-MOUNT review fix round, filed as register
|
||||
/// AP-223): with AD-101 retired, a caller could otherwise reach Finish
|
||||
/// with heritage/gender still unset. Retail's own <c>DoFinish</c> never
|
||||
/// checks this because <c>RandomizeCharacter</c> guarantees it can't
|
||||
/// happen — this is acdream's own defensive backstop for any caller that
|
||||
/// bypasses the App layer's screen-open roll.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryBeginFinish_HeritageUnset_IsRefused()
|
||||
{
|
||||
RuntimeCharacterCreationState state = CreateActive();
|
||||
state.TrySetName("Adventurer");
|
||||
|
||||
bool accepted = state.TryBeginFinish(
|
||||
0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal);
|
||||
|
||||
Assert.False(accepted);
|
||||
Assert.True(refusal.HeritageOrGenderUnset);
|
||||
Assert.False(refusal.NoName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBeginFinish_GenderUnset_IsRefused()
|
||||
{
|
||||
RuntimeCharacterCreationState state = CreateActive();
|
||||
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||
state.TrySetName("Adventurer");
|
||||
|
||||
bool accepted = state.TryBeginFinish(
|
||||
0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal);
|
||||
|
||||
Assert.False(accepted);
|
||||
Assert.True(refusal.HeritageOrGenderUnset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBeginFinish_RosterAtSlotCap_IsRefused()
|
||||
{
|
||||
|
|
@ -700,4 +736,162 @@ public sealed class RuntimeCharacterCreationStateTests
|
|||
|
||||
Assert.Null(state.Snapshot.LastRejection);
|
||||
}
|
||||
|
||||
// ── Randomize (Campaign CC slice CC5, RandomizeCharacter port) ───────
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>CharGenState::RandomizeCharacter @ 0x005c6d80</c>: rolls a
|
||||
/// heritage id in [1,4] (the four HUMAN groups, never the other nine),
|
||||
/// a gender in [1,2], and freezes both non-Unset. A 200-iteration sweep
|
||||
/// with a fresh seeded RNG per iteration proves the heritage roll never
|
||||
/// escapes the 1-4 human-only range even though the fixture ALSO
|
||||
/// carries a non-human Olthoi heritage (id 12) and an out-of-range
|
||||
/// "Impoverished" heritage (id 90) that a broken roll could otherwise
|
||||
/// land on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryRandomizeCharacter_RollsOnlyTheFourHumanHeritagesAndAGender()
|
||||
{
|
||||
for (int seed = 0; seed < 200; seed++)
|
||||
{
|
||||
var state = new RuntimeCharacterCreationState(
|
||||
RuntimeCharacterCreationStateFixture.Build(),
|
||||
new Random(seed));
|
||||
state.Begin(new RuntimeGenerationToken(1));
|
||||
|
||||
Assert.True(state.TryRandomizeCharacter());
|
||||
|
||||
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
|
||||
Assert.InRange(snapshot.HeritageId, 1u, 4u);
|
||||
Assert.True(snapshot.GenderKey is 1u or 2u);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRandomizeCharacter_RollsAppearanceClothingTemplateAndStartArea()
|
||||
{
|
||||
var state = new RuntimeCharacterCreationState(
|
||||
RuntimeCharacterCreationStateFixture.Build(),
|
||||
new Random(7));
|
||||
state.Begin(new RuntimeGenerationToken(1));
|
||||
|
||||
Assert.True(state.TryRandomizeCharacter());
|
||||
|
||||
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
|
||||
// Every list in the fixture's shared gender record is non-empty, so
|
||||
// a full randomize must leave nothing Unset.
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.EyesStrip);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairColor);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.ShirtStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.TrousersStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.FootwearStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template);
|
||||
// Template is one of the PRESET rows (never index 0/Custom) —
|
||||
// RandomizeTemplate @ 0x005c6500's RandInt(count-1,...)+1 shape.
|
||||
Assert.NotEqual(0u, snapshot.Template);
|
||||
Assert.True(snapshot.StartArea is 0 or 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>RandomizeTemplateLocked</c>'s own Olthoi/OlthoiAcid branch
|
||||
/// (mirroring <c>CharGenState::RandomizeTemplate</c>'s force-to-template-0
|
||||
/// arm) is UNREACHABLE through <see cref="RuntimeCharacterCreationState.TryRandomizeCharacter"/>
|
||||
/// specifically — that caller's own heritage roll is always one of the
|
||||
/// four HUMAN ids (never Olthoi), matching retail's identical
|
||||
/// architecture (<c>RandomizeCharacter</c>'s heritage roll and
|
||||
/// <c>RandomizeTemplate</c>'s Olthoi branch are independent call paths;
|
||||
/// retail never composes them either, since a random CHARACTER is never
|
||||
/// Olthoi). The branch is not otherwise exposed as a standalone command
|
||||
/// this slice (out of CC5's named scope), so its OBSERVABLE behavior —
|
||||
/// selecting Olthoi always forces template 0 — is already covered by
|
||||
/// <c>TrySelectHeritage_Olthoi...</c>/<c>ApplyTemplate</c> coverage
|
||||
/// elsewhere in this file; this test only pins that a full
|
||||
/// <c>TryRandomizeCharacter</c> roll never lands on Olthoi in the first
|
||||
/// place, over enough iterations to catch a boundary-off-by-one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryRandomizeCharacter_NeverRollsANonHumanHeritage()
|
||||
{
|
||||
for (int seed = 0; seed < 200; seed++)
|
||||
{
|
||||
var state = new RuntimeCharacterCreationState(
|
||||
RuntimeCharacterCreationStateFixture.Build(),
|
||||
new Random(seed));
|
||||
state.Begin(new RuntimeGenerationToken(1));
|
||||
|
||||
Assert.True(state.TryRandomizeCharacter());
|
||||
|
||||
Assert.NotEqual(RuntimeCharacterCreationStateFixture.OlthoiId, state.Snapshot.HeritageId);
|
||||
Assert.NotEqual(RuntimeCharacterCreationStateFixture.ImpoverishedId, state.Snapshot.HeritageId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRandomizeCharacter_Inactive_IsRejected()
|
||||
{
|
||||
var state = new RuntimeCharacterCreationState(
|
||||
RuntimeCharacterCreationStateFixture.Build());
|
||||
Assert.False(state.TryRandomizeCharacter());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRandomizeAppearance_RequiresHeritageAndGender()
|
||||
{
|
||||
RuntimeCharacterCreationState state = CreateActive();
|
||||
Assert.False(state.TryRandomizeAppearance());
|
||||
|
||||
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||
Assert.False(state.TryRandomizeAppearance());
|
||||
|
||||
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||
Assert.True(state.TryRandomizeAppearance());
|
||||
Assert.NotEqual(
|
||||
RuntimeCharacterCreationAppearance.Unset,
|
||||
state.Snapshot.Appearance.HairStyle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRandomizeClothing_RollsAllFourGearSlots()
|
||||
{
|
||||
RuntimeCharacterCreationState state = CreateActive();
|
||||
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||
|
||||
Assert.True(state.TryRandomizeClothing());
|
||||
|
||||
RuntimeCharacterCreationAppearance a = state.Snapshot.Appearance;
|
||||
// Headgear excludes-current with the +1 Unset-ring reindex — the
|
||||
// fixture's single headgear style means the roll can only land on
|
||||
// style 0 or Unset; either is a valid outcome of the ring, so this
|
||||
// just confirms the call actually touched the field (shirt/trousers/
|
||||
// footwear below have no Unset ring and must land on their one
|
||||
// style).
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.ShirtStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.TrousersStyle);
|
||||
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.FootwearStyle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>RandInt(int,int) @ 0x00684420</c>'s own decompiled shape: re-roll
|
||||
/// until the result differs from the excluded value, UNLESS there is
|
||||
/// only one possible outcome (<paramref name="count"/> <= 1), which
|
||||
/// returns 0 immediately without ever comparing against exclude (the
|
||||
/// guard that keeps the loop from spinning forever). This drives
|
||||
/// <see cref="RuntimeCharacterCreationState.TryRandomizeClothing"/>
|
||||
/// enough times to statistically prove the shirt slot (the fixture's
|
||||
/// single-style list) never gets stuck — count<=1 must short-circuit.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryRandomizeClothing_SingleOptionList_NeverHangs()
|
||||
{
|
||||
RuntimeCharacterCreationState state = CreateActive();
|
||||
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
Assert.True(state.TryRandomizeClothing());
|
||||
|
||||
Assert.Equal(0u, state.Snapshot.Appearance.ShirtStyle);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue