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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -540,7 +540,8 @@ internal sealed class FrameRootCompositionPhase
|
||||||
new PrivateEntityViewportFrameGroup(
|
new PrivateEntityViewportFrameGroup(
|
||||||
live.PaperdollPresenter,
|
live.PaperdollPresenter,
|
||||||
live.CreatureAppraisalPresenter,
|
live.CreatureAppraisalPresenter,
|
||||||
live.ChargenPreviewController),
|
live.ChargenPreviewController,
|
||||||
|
live.SummaryPreviewController),
|
||||||
retainedGameplayUi,
|
retainedGameplayUi,
|
||||||
// The ImGui developer-tools frontend was removed at Campaign V
|
// The ImGui developer-tools frontend was removed at Campaign V
|
||||||
// slice V11; this optional hook is unbound until a follow-up
|
// slice V11; this optional hook is unbound until a follow-up
|
||||||
|
|
|
||||||
|
|
@ -1007,6 +1007,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
DatStringResolver.ComputeHash(key));
|
DatStringResolver.ComputeHash(key));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
SetName: late.GameRuntime.CharacterCreationSetName,
|
||||||
|
AcknowledgeRejection: late.GameRuntime.CharacterCreationAcknowledgeRejection,
|
||||||
|
RandomizeCharacter: late.GameRuntime.CharacterCreationRandomizeCharacter,
|
||||||
|
RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance,
|
||||||
|
RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing,
|
||||||
OpenOnStart: d.Options.OpenCharacterCreationOnStart)
|
OpenOnStart: d.Options.OpenCharacterCreationOnStart)
|
||||||
: null);
|
: null);
|
||||||
RetailUiRuntime runtime = lease.Mount(
|
RetailUiRuntime runtime = lease.Mount(
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,28 @@ internal sealed class DeferredGameRuntimeStateCommands
|
||||||
Invoke((commands, generation) =>
|
Invoke((commands, generation) =>
|
||||||
commands.CharacterCreation.SetShade(generation, slot, value));
|
commands.CharacterCreation.SetShade(generation, slot, value));
|
||||||
|
|
||||||
|
// ── Campaign CC slice CC5: Summary page + RandomizeCharacter commands ──
|
||||||
|
|
||||||
|
public RuntimeCommandResult CharacterCreationSetName(string name) =>
|
||||||
|
Invoke((commands, generation) =>
|
||||||
|
commands.CharacterCreation.SetName(generation, name));
|
||||||
|
|
||||||
|
public RuntimeCommandResult CharacterCreationAcknowledgeRejection() =>
|
||||||
|
Invoke((commands, generation) =>
|
||||||
|
commands.CharacterCreation.AcknowledgeRejection(generation));
|
||||||
|
|
||||||
|
public RuntimeCommandResult CharacterCreationRandomizeCharacter() =>
|
||||||
|
Invoke((commands, generation) =>
|
||||||
|
commands.CharacterCreation.RandomizeCharacter(generation));
|
||||||
|
|
||||||
|
public RuntimeCommandResult CharacterCreationRandomizeAppearance() =>
|
||||||
|
Invoke((commands, generation) =>
|
||||||
|
commands.CharacterCreation.RandomizeAppearance(generation));
|
||||||
|
|
||||||
|
public RuntimeCommandResult CharacterCreationRandomizeClothing() =>
|
||||||
|
Invoke((commands, generation) =>
|
||||||
|
commands.CharacterCreation.RandomizeClothing(generation));
|
||||||
|
|
||||||
// ── Campaign FA slice FA4: fellowship page commands ─────────────────
|
// ── Campaign FA slice FA4: fellowship page commands ─────────────────
|
||||||
// Same "capture view+commands under one generation" shape as every
|
// Same "capture view+commands under one generation" shape as every
|
||||||
// method above — a displaced session (reconnect mid-click) can never
|
// method above — a displaced session (reconnect mid-click) can never
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,11 @@ internal sealed record LivePresentationResult(
|
||||||
// a leased composition resource, mirroring PaperdollViewportRenderer).
|
// a leased composition resource, mirroring PaperdollViewportRenderer).
|
||||||
ChargenPreviewRenderer? ChargenPreviewRenderer,
|
ChargenPreviewRenderer? ChargenPreviewRenderer,
|
||||||
ChargenPreviewController? ChargenPreviewController,
|
ChargenPreviewController? ChargenPreviewController,
|
||||||
|
// Campaign CC slice CC5: the Summary page's own gmCG3DView instance —
|
||||||
|
// a SEPARATE leased renderer/controller pair, same split reasoning as
|
||||||
|
// the Appearance preview fields immediately above.
|
||||||
|
ChargenPreviewRenderer? SummaryPreviewRenderer,
|
||||||
|
ChargenPreviewController? SummaryPreviewController,
|
||||||
WbFrustum EnvCellFrustum,
|
WbFrustum EnvCellFrustum,
|
||||||
EnvCellRenderer? EnvCellRenderer,
|
EnvCellRenderer? EnvCellRenderer,
|
||||||
LandblockPresentationPipeline LandblockPipeline,
|
LandblockPresentationPipeline LandblockPipeline,
|
||||||
|
|
@ -1102,6 +1107,82 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
+ "time — the Appearance page's zoom/rotate controls and "
|
+ "time — the Appearance page's zoom/rotate controls and "
|
||||||
+ "3D preview will not function this session.");
|
+ "3D preview will not function this session.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Campaign CC slice CC5: the Summary page's OWN gmCG3DView instance
|
||||||
|
// (gmCGSummaryPage::InitializePage @0x0047bbf0, confirmed a SEPARATE
|
||||||
|
// instance from the Appearance page's own during the CC6b-MOUNT
|
||||||
|
// review) — same one-shot binding shape as the Appearance preview
|
||||||
|
// immediately above (AP-221's own disposition applies here too: a
|
||||||
|
// DAT/resource read not ready on this exact composition frame means
|
||||||
|
// the Summary preview stays permanently unbound for the session,
|
||||||
|
// same tracked follow-up as the Appearance preview). No zoom/rotate
|
||||||
|
// control surface is wired — retail's Summary page has no such
|
||||||
|
// buttons (only <c>StartAnimation</c>'s idle loop and a fixed 180°
|
||||||
|
// heading), so this controller's ZoomIn/RotateClockwise etc. simply
|
||||||
|
// never get called.
|
||||||
|
CompositionAcquisitionScope.CompositionAcquisitionLease<
|
||||||
|
ChargenPreviewRenderer>? summaryPreviewLease = null;
|
||||||
|
ChargenPreviewController? summaryPreviewController = null;
|
||||||
|
if (dispatcherLease.Resource is { } summaryDispatcher
|
||||||
|
&& interaction.RetainedUi?.Runtime.SummaryPreviewViewportWidget is { } summaryViewport)
|
||||||
|
{
|
||||||
|
var summaryCamera = new ChargenPreviewCamera();
|
||||||
|
summaryPreviewLease = scope.Acquire(
|
||||||
|
"summary preview viewport",
|
||||||
|
() => new ChargenPreviewRenderer(
|
||||||
|
worldPassScope
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The graphics backend must publish a world pass scope."),
|
||||||
|
host.GpuDevice,
|
||||||
|
host.GpuFrameLifetime,
|
||||||
|
summaryDispatcher,
|
||||||
|
foundation.SceneLighting!,
|
||||||
|
foundation.TextureCache,
|
||||||
|
foundation.MeshAdapter!,
|
||||||
|
camera: summaryCamera),
|
||||||
|
static value => value.Dispose());
|
||||||
|
IUiViewportRenderer? previousSummaryRenderer = summaryViewport.Renderer;
|
||||||
|
summaryViewport.Renderer = summaryPreviewLease.Resource;
|
||||||
|
bindings.AdoptRelease(
|
||||||
|
"summary preview viewport target",
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(summaryViewport.Renderer, summaryPreviewLease.Resource))
|
||||||
|
summaryViewport.Renderer = previousSummaryRenderer;
|
||||||
|
});
|
||||||
|
|
||||||
|
var summaryCatalog = new AcDream.Content.CharGen.ChargenAppearanceCatalog(content.Dats);
|
||||||
|
summaryPreviewController = new ChargenPreviewController(
|
||||||
|
summaryPreviewLease.Resource,
|
||||||
|
summaryCamera,
|
||||||
|
new RetailChargenPreviewFrameView(
|
||||||
|
summaryViewport,
|
||||||
|
new RetailSummaryPreviewPageVisibility(interaction.RetainedUi.Runtime)),
|
||||||
|
content.Dats,
|
||||||
|
content.AnimationLoader,
|
||||||
|
summaryCatalog,
|
||||||
|
summaryCatalog,
|
||||||
|
d.DatLock);
|
||||||
|
interaction.RetainedUi.Runtime.SummaryPreviewControl = summaryPreviewController;
|
||||||
|
bindings.AdoptRelease(
|
||||||
|
"summary preview control",
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(
|
||||||
|
interaction.RetainedUi.Runtime.SummaryPreviewControl,
|
||||||
|
summaryPreviewController))
|
||||||
|
{
|
||||||
|
interaction.RetainedUi.Runtime.SummaryPreviewControl = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (dispatcherLease.Resource is not null && interaction.RetainedUi is not null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
"[UI] summary preview viewport unavailable at composition "
|
||||||
|
+ "time — the Summary page's 3D preview will not function "
|
||||||
|
+ "this session.");
|
||||||
|
}
|
||||||
Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated);
|
Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated);
|
||||||
|
|
||||||
var envCellFrustum = new WbFrustum();
|
var envCellFrustum = new WbFrustum();
|
||||||
|
|
@ -1410,6 +1491,8 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
creatureAppraisalPresenter,
|
creatureAppraisalPresenter,
|
||||||
chargenPreviewLease?.Resource,
|
chargenPreviewLease?.Resource,
|
||||||
chargenPreviewController,
|
chargenPreviewController,
|
||||||
|
summaryPreviewLease?.Resource,
|
||||||
|
summaryPreviewController,
|
||||||
envCellFrustum,
|
envCellFrustum,
|
||||||
envCellLease.Resource,
|
envCellLease.Resource,
|
||||||
landblockPipeline,
|
landblockPipeline,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,19 @@ internal sealed class RetailChargenPreviewPageVisibility : IChargenPreviewPageVi
|
||||||
public bool IsVisible => _runtime.IsChargenPreviewPageVisible;
|
public bool IsVisible => _runtime.IsChargenPreviewPageVisible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Campaign CC slice CC5: the Summary page's own visibility gate —
|
||||||
|
/// same shape as <see cref="RetailChargenPreviewPageVisibility"/>, reading
|
||||||
|
/// <c>RetailUiRuntime.IsSummaryPreviewPageVisible</c> instead.</summary>
|
||||||
|
internal sealed class RetailSummaryPreviewPageVisibility : IChargenPreviewPageVisibility
|
||||||
|
{
|
||||||
|
private readonly AcDream.App.UI.RetailUiRuntime _runtime;
|
||||||
|
|
||||||
|
public RetailSummaryPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) =>
|
||||||
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||||
|
|
||||||
|
public bool IsVisible => _runtime.IsSummaryPreviewPageVisible;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Retained-UI visibility + texture publication, mirroring
|
/// <summary>Retained-UI visibility + texture publication, mirroring
|
||||||
/// <c>RetailPaperdollFrameView</c>.</summary>
|
/// <c>RetailPaperdollFrameView</c>.</summary>
|
||||||
internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView
|
internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView
|
||||||
|
|
|
||||||
|
|
@ -434,6 +434,10 @@ public sealed class GameWindow :
|
||||||
// viewports above.
|
// viewports above.
|
||||||
private AcDream.App.Rendering.ChargenPreviewRenderer? _chargenPreviewRenderer;
|
private AcDream.App.Rendering.ChargenPreviewRenderer? _chargenPreviewRenderer;
|
||||||
private AcDream.App.Rendering.ChargenPreviewController? _chargenPreviewController;
|
private AcDream.App.Rendering.ChargenPreviewController? _chargenPreviewController;
|
||||||
|
// Campaign CC slice CC5: the Summary page's own gmCG3DView instance —
|
||||||
|
// a SEPARATE renderer/controller pair from the Appearance preview above.
|
||||||
|
private AcDream.App.Rendering.ChargenPreviewRenderer? _summaryPreviewRenderer;
|
||||||
|
private AcDream.App.Rendering.ChargenPreviewController? _summaryPreviewController;
|
||||||
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
||||||
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
||||||
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
|
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
|
||||||
|
|
@ -1123,6 +1127,8 @@ public sealed class GameWindow :
|
||||||
_creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter;
|
_creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter;
|
||||||
_chargenPreviewRenderer = result.ChargenPreviewRenderer;
|
_chargenPreviewRenderer = result.ChargenPreviewRenderer;
|
||||||
_chargenPreviewController = result.ChargenPreviewController;
|
_chargenPreviewController = result.ChargenPreviewController;
|
||||||
|
_summaryPreviewRenderer = result.SummaryPreviewRenderer;
|
||||||
|
_summaryPreviewController = result.SummaryPreviewController;
|
||||||
_envCellFrustum = result.EnvCellFrustum;
|
_envCellFrustum = result.EnvCellFrustum;
|
||||||
_envCellRenderer = result.EnvCellRenderer;
|
_envCellRenderer = result.EnvCellRenderer;
|
||||||
_landblockPresentationPipeline = result.LandblockPipeline;
|
_landblockPresentationPipeline = result.LandblockPipeline;
|
||||||
|
|
@ -1770,6 +1776,8 @@ public sealed class GameWindow :
|
||||||
_creatureAppraisalViewportRenderer,
|
_creatureAppraisalViewportRenderer,
|
||||||
_chargenPreviewRenderer,
|
_chargenPreviewRenderer,
|
||||||
_chargenPreviewController,
|
_chargenPreviewController,
|
||||||
|
_summaryPreviewRenderer,
|
||||||
|
_summaryPreviewController,
|
||||||
_wbDrawDispatcher,
|
_wbDrawDispatcher,
|
||||||
_envCellRenderer,
|
_envCellRenderer,
|
||||||
_portalDepthMask,
|
_portalDepthMask,
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,14 @@ internal sealed record RenderShutdownRoots(
|
||||||
CreatureAppraisalViewportRenderer? CreatureAppraisal,
|
CreatureAppraisalViewportRenderer? CreatureAppraisal,
|
||||||
ChargenPreviewRenderer? ChargenPreview,
|
ChargenPreviewRenderer? ChargenPreview,
|
||||||
ChargenPreviewController? ChargenPreviewController,
|
ChargenPreviewController? ChargenPreviewController,
|
||||||
|
// Campaign CC slice CC5: the Summary page's OWN gmCG3DView instance —
|
||||||
|
// same guard/shutdown shape as the Appearance-page preview above (a
|
||||||
|
// SEPARATE renderer/controller pair, not a shared one — retail's own
|
||||||
|
// gmCGSummaryPage::InitializePage @0x0047bbf0 constructs its own
|
||||||
|
// gmCG3DView, confirmed a distinct instance from the Appearance page's
|
||||||
|
// during the CC6b-MOUNT review).
|
||||||
|
ChargenPreviewRenderer? SummaryPreview,
|
||||||
|
ChargenPreviewController? SummaryPreviewController,
|
||||||
WbDrawDispatcher? DrawDispatcher,
|
WbDrawDispatcher? DrawDispatcher,
|
||||||
EnvCellRenderer? EnvironmentCells,
|
EnvCellRenderer? EnvironmentCells,
|
||||||
PortalDepthMaskRenderer? PortalDepthMask,
|
PortalDepthMaskRenderer? PortalDepthMask,
|
||||||
|
|
@ -493,6 +501,8 @@ internal static class GameWindowShutdownManifest
|
||||||
() => render.CreatureAppraisal?.Dispose()),
|
() => render.CreatureAppraisal?.Dispose()),
|
||||||
Hard("chargen preview control", () => render.ChargenPreviewController?.Dispose()),
|
Hard("chargen preview control", () => render.ChargenPreviewController?.Dispose()),
|
||||||
Hard("chargen preview viewport", () => render.ChargenPreview?.Dispose()),
|
Hard("chargen preview viewport", () => render.ChargenPreview?.Dispose()),
|
||||||
|
Hard("summary preview control", () => render.SummaryPreviewController?.Dispose()),
|
||||||
|
Hard("summary preview viewport", () => render.SummaryPreview?.Dispose()),
|
||||||
Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()),
|
Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()),
|
||||||
Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
|
Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
|
||||||
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),
|
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,21 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
RuntimeGenerationToken expectedGeneration) =>
|
RuntimeGenerationToken expectedGeneration) =>
|
||||||
owner.ExecuteCharacterCreation(
|
owner.ExecuteCharacterCreation(
|
||||||
commands => commands.AcknowledgeRejection(expectedGeneration));
|
commands => commands.AcknowledgeRejection(expectedGeneration));
|
||||||
|
|
||||||
|
public RuntimeCommandResult RandomizeCharacter(
|
||||||
|
RuntimeGenerationToken expectedGeneration) =>
|
||||||
|
owner.ExecuteCharacterCreation(
|
||||||
|
commands => commands.RandomizeCharacter(expectedGeneration));
|
||||||
|
|
||||||
|
public RuntimeCommandResult RandomizeAppearance(
|
||||||
|
RuntimeGenerationToken expectedGeneration) =>
|
||||||
|
owner.ExecuteCharacterCreation(
|
||||||
|
commands => commands.RandomizeAppearance(expectedGeneration));
|
||||||
|
|
||||||
|
public RuntimeCommandResult RandomizeClothing(
|
||||||
|
RuntimeGenerationToken expectedGeneration) =>
|
||||||
|
owner.ExecuteCharacterCreation(
|
||||||
|
commands => commands.RandomizeClothing(expectedGeneration));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class AdapterCharacterCreationObserver(
|
private sealed class AdapterCharacterCreationObserver(
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,26 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
RebuildPreview(view, snapshot);
|
RebuildPreview(view, snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign CC slice CC5: ports the Appearance case of
|
||||||
|
/// <c>gmCharGenMainUI::DoRandom @ 0x004e7d70</c> (case 3) —
|
||||||
|
/// <c>m_eCurType == ECG_CHOICE_CLOTHES ->
|
||||||
|
/// CharGenState::RandomizeClothing(state, 1)</c>, else
|
||||||
|
/// <c>CharGenState::RandomizeAppearance(state, 0)</c>. Retires the
|
||||||
|
/// Appearance half of register AP-212 (the primitives are now real,
|
||||||
|
/// faithful ports — see <c>RuntimeCharacterCreationState</c>'s own
|
||||||
|
/// Randomize section — not a uniform-pick approximation).
|
||||||
|
/// </summary>
|
||||||
|
internal void Randomize()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
if (_currentChoice == Choice.Clothes)
|
||||||
|
_bindings.RandomizeClothing?.Invoke();
|
||||||
|
else
|
||||||
|
_bindings.RandomizeAppearance?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Gender / Face-Clothes sub-tab ──────────────────────────────────
|
// ── Gender / Face-Clothes sub-tab ──────────────────────────────────
|
||||||
|
|
||||||
private void SelectChoice(Choice choice)
|
private void SelectChoice(Choice choice)
|
||||||
|
|
|
||||||
372
src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs
Normal file
372
src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.Core.CharGen;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Summary page (<c>gmCGSummaryPage</c>, root <c>0x100003d6</c>) —
|
||||||
|
/// Campaign CC slice CC5, retiring the TS-82 content-inert placeholder.
|
||||||
|
/// Decomp anchors: <c>gmCGSummaryPage::InitializePage @ 0x0047bbf0</c>
|
||||||
|
/// (widget ids, its OWN <c>gmCG3DView</c> instance, camera set + 180°
|
||||||
|
/// heading + <c>StartAnimation</c> — a live idle-animated preview, not a
|
||||||
|
/// static frozen frame), <c>::SetSummaryText @ 0x0047b1d0</c> (the listbox's
|
||||||
|
/// three-template row content: template 0 = a single <c>UiText</c> line,
|
||||||
|
/// template 1 = a category-header <c>UiText</c>, template 2 = a two-column
|
||||||
|
/// key/value <c>UiText</c> pair — live-DAT-probe-confirmed against the
|
||||||
|
/// installed EoR dat, resolving DID <c>0x2100004C</c> elements
|
||||||
|
/// <c>0x100002F8/FA/FB</c>), <c>::ListenToElementMessage @ 0x0047bf40</c>
|
||||||
|
/// (the name field's commit-on-idMessage-0x12-or-0x44 dispatch, the
|
||||||
|
/// >32-char <c>ID_CharGen_NameTooLong</c> reject-and-revert path — see
|
||||||
|
/// <see cref="CommitNameFromField"/>'s own doc comment for the 32-vs-33
|
||||||
|
/// reconciliation), <c>::DoNameLimitDialog @ 0x0047bd80</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Listbox content scope cut (register-worthy, AP-213's own precedent):</b>
|
||||||
|
/// retail's skills section walks FOUR buckets (Specialized/Trained/
|
||||||
|
/// UseableUntrained/UnuseableUntrained) and lists every skill name in each.
|
||||||
|
/// This port lists Specialized and Trained only — the two buckets a player
|
||||||
|
/// actually spent credits on and would review before Finishing — and skips
|
||||||
|
/// the two Untrained buckets (which would otherwise list the ~50 skills the
|
||||||
|
/// player did NOT touch, adding volume without decision-relevant
|
||||||
|
/// information). Health/Stamina/Mana reuse
|
||||||
|
/// <see cref="CharacterCreationProfessionPage"/>'s own already-cited
|
||||||
|
/// <c>UpdateAttributeValues @ 0x00482450</c> formulas (Health=Endurance/2,
|
||||||
|
/// Stamina=Endurance, Mana=Self) rather than this page's OWN
|
||||||
|
/// <c>SetSummaryText</c> call site, whose two GetAttribute calls for
|
||||||
|
/// Health/Stamina are decompiler-ambiguous (both show a literal attribute
|
||||||
|
/// index of 2 — ProfessionPage's site is the cleaner citation).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CharacterCreationSummaryPage : IDisposable
|
||||||
|
{
|
||||||
|
internal const uint ListBoxId = 0x10000400u;
|
||||||
|
internal const uint ScrollId = 0x10000401u;
|
||||||
|
internal const uint NameTextId = 0x10000402u;
|
||||||
|
internal const uint HowToTextId = 0x10000404u;
|
||||||
|
internal const uint ViewportId = 0x10000406u;
|
||||||
|
|
||||||
|
/// <summary>Row-template child ids, live-DAT-probe-confirmed:
|
||||||
|
/// template 0's single line, template 1's header line, template 2's
|
||||||
|
/// key/value pair.</summary>
|
||||||
|
private const uint SingleLineTextId = 0x100002F9u;
|
||||||
|
private const uint HeaderTextId = 0x100000FEu;
|
||||||
|
private const uint KeyTextId = 0x100002FCu;
|
||||||
|
private const uint ValueTextId = 0x100002FDu;
|
||||||
|
|
||||||
|
/// <summary>Retail's <c>name[33]</c> buffer (32 usable chars + null
|
||||||
|
/// terminator — <c>RuntimeCharacterCreationState.TrySetName</c>'s own
|
||||||
|
/// already-established storage cap). The decompiled UI-side check at
|
||||||
|
/// <c>ListenToElementMessage @ 0x0047bfd1</c> compares the raw input
|
||||||
|
/// length against the literal <c>0x21</c> (33) — one more than this —
|
||||||
|
/// but that comparison's exact base (visible character count vs. an
|
||||||
|
/// internal length-prefix accounting the decompiler didn't resolve
|
||||||
|
/// cleanly) is not fully certain from the pseudo-C. Using 32 here keeps
|
||||||
|
/// the UI-level reject-and-revert threshold CONSISTENT with the
|
||||||
|
/// already-reviewed storage cap rather than trusting an ambiguous
|
||||||
|
/// 1-off decomp literal over that established contract.</summary>
|
||||||
|
private const int MaxNameLength = 32;
|
||||||
|
|
||||||
|
private readonly CharacterCreationRuntimeBindings _bindings;
|
||||||
|
private readonly RetailDialogFactory _dialogs;
|
||||||
|
private readonly string _nameTooLongMessage;
|
||||||
|
private readonly UiTemplateListBox? _list;
|
||||||
|
private readonly UiField? _nameField;
|
||||||
|
private string _lastCommittedName = string.Empty;
|
||||||
|
private bool _suppressNextFieldEvent;
|
||||||
|
private uint _nameTooLongDialogContext;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Late-bound preview control seam — see
|
||||||
|
/// <see cref="IChargenPreviewControl"/>'s own doc comment for why this
|
||||||
|
/// page cannot receive the real renderer at construction time.</summary>
|
||||||
|
internal IChargenPreviewControl? PreviewControl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The authored viewport (<c>0x10000406</c>) — Summary's OWN
|
||||||
|
/// <c>gmCG3DView</c> instance, distinct from the Appearance page's.</summary>
|
||||||
|
internal UiViewport? Viewport { get; }
|
||||||
|
|
||||||
|
internal CharacterCreationSummaryPage(
|
||||||
|
UiElement pageRoot,
|
||||||
|
CharacterCreationRuntimeBindings bindings,
|
||||||
|
RetailDialogFactory dialogs,
|
||||||
|
string nameTooLongMessage)
|
||||||
|
{
|
||||||
|
_bindings = bindings;
|
||||||
|
_dialogs = dialogs;
|
||||||
|
_nameTooLongMessage = nameTooLongMessage;
|
||||||
|
|
||||||
|
_list = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox;
|
||||||
|
|
||||||
|
_nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField;
|
||||||
|
if (_nameField is not null)
|
||||||
|
{
|
||||||
|
// NameInputFilter @ 0x004663b0: ASCII letters, space, apostrophe,
|
||||||
|
// hyphen — everything else is rejected per keystroke.
|
||||||
|
_nameField.CharacterFilter = NameInputFilter;
|
||||||
|
// Deliberately NOT capping UiField.MaxCharacters at MaxNameLength:
|
||||||
|
// retail's own >32-char check (ListenToElementMessage's own
|
||||||
|
// GetText().m_charbuffer length compare) only fires at COMMIT
|
||||||
|
// time (idMessage 0x12/0x44), which means the textbox itself
|
||||||
|
// accepts MORE than 32 characters while typing — the
|
||||||
|
// DoNameLimitDialog reject-and-revert path exists specifically
|
||||||
|
// to catch that post-typing case. A per-keystroke cap here would
|
||||||
|
// make that whole retail code path structurally unreachable.
|
||||||
|
// ListenToElementMessage @ 0x0047bf50: the name field commits on
|
||||||
|
// idMessage 0x12 OR 0x44 — acdream's UiField exposes those two
|
||||||
|
// triggers as OnFocusLost (clicking/tabbing away) and OnSubmit
|
||||||
|
// (Enter). Both route through the same commit path.
|
||||||
|
_nameField.OnFocusLost = CommitNameFromField;
|
||||||
|
_nameField.OnSubmit = CommitNameFromField;
|
||||||
|
_nameField.ClearOnSubmit = false;
|
||||||
|
_nameField.RecordHistory = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Viewport = UiElement.FindDescendant(pageRoot, ViewportId) as UiViewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Refresh(
|
||||||
|
IRuntimeCharacterCreationView view,
|
||||||
|
RuntimeCharacterCreationSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Keep the field's displayed text in sync with the committed name
|
||||||
|
// unless the player is actively typing (a mid-edit Refresh — driven
|
||||||
|
// by an unrelated selection change elsewhere on the screen — must
|
||||||
|
// not clobber their in-progress keystrokes).
|
||||||
|
if (_nameField is { IsFocused: false } field && field.Text != snapshot.Name)
|
||||||
|
{
|
||||||
|
_suppressNextFieldEvent = true;
|
||||||
|
field.SetText(snapshot.Name);
|
||||||
|
_lastCommittedName = snapshot.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
RebuildListbox(view, snapshot);
|
||||||
|
RebuildPreview(view, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Name field (ListenToElementMessage @ 0x0047bf40) ────────────────
|
||||||
|
|
||||||
|
private void CommitNameFromField(string text)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
if (_suppressNextFieldEvent)
|
||||||
|
{
|
||||||
|
_suppressNextFieldEvent = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.Length > MaxNameLength)
|
||||||
|
{
|
||||||
|
// DoNameLimitDialog @ 0x0047bd80 (ID_CharGen_NameTooLong):
|
||||||
|
// revert the field to the last COMMITTED name rather than the
|
||||||
|
// rejected input.
|
||||||
|
_nameField?.SetText(_lastCommittedName);
|
||||||
|
ShowNameTooLongDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastCommittedName = text;
|
||||||
|
_bindings.SetName?.Invoke(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowNameTooLongDialog()
|
||||||
|
{
|
||||||
|
// DoNameLimitDialog's own guard: a context already open is a no-op.
|
||||||
|
if (_nameTooLongDialogContext != 0u)
|
||||||
|
return;
|
||||||
|
_nameTooLongDialogContext = _dialogs.MakeMessage(
|
||||||
|
_nameTooLongMessage,
|
||||||
|
data =>
|
||||||
|
{
|
||||||
|
_ = data;
|
||||||
|
_nameTooLongDialogContext = 0u;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ports <c>NameInputFilter @ 0x004663b0</c> exactly: ASCII
|
||||||
|
/// letters (<c>isalpha</c>), space (<c>0x20</c>), apostrophe
|
||||||
|
/// (<c>0x27</c>), or hyphen (<c>0x2d</c>).</summary>
|
||||||
|
private static bool NameInputFilter(char c) =>
|
||||||
|
(c < 0x100 && char.IsAsciiLetter(c)) || c is ' ' or '\'' or '-';
|
||||||
|
|
||||||
|
// ── Listbox (SetSummaryText @ 0x0047b1d0) ───────────────────────────
|
||||||
|
|
||||||
|
private void RebuildListbox(
|
||||||
|
IRuntimeCharacterCreationView view,
|
||||||
|
RuntimeCharacterCreationSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (_list is null || _list.Templates.Count < 3)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_list.Flush();
|
||||||
|
|
||||||
|
if (!view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage))
|
||||||
|
return;
|
||||||
|
|
||||||
|
UiTemplateListEntry lineTemplate = _list.Templates[0];
|
||||||
|
UiTemplateListEntry headerTemplate = _list.Templates[1];
|
||||||
|
UiTemplateListEntry pairTemplate = _list.Templates[2];
|
||||||
|
|
||||||
|
AddLine(lineTemplate, "Profession: " + ProfessionName(heritage, snapshot.Template));
|
||||||
|
AddLine(lineTemplate, "Gender: " + GenderName(heritage, snapshot.GenderKey));
|
||||||
|
AddLine(lineTemplate, "Heritage: " + heritage.Name);
|
||||||
|
AddLine(lineTemplate, "Starting Town: " + StarterAreaName(view.Options, snapshot.StartArea));
|
||||||
|
|
||||||
|
AddHeader(headerTemplate, "Attributes");
|
||||||
|
ChargenAttributeValues a = snapshot.Attributes;
|
||||||
|
AddPair(pairTemplate, "Strength", a.Strength);
|
||||||
|
AddPair(pairTemplate, "Endurance", a.Endurance);
|
||||||
|
AddPair(pairTemplate, "Coordination", a.Coordination);
|
||||||
|
AddPair(pairTemplate, "Quickness", a.Quickness);
|
||||||
|
AddPair(pairTemplate, "Focus", a.Focus);
|
||||||
|
AddPair(pairTemplate, "Self", a.Self);
|
||||||
|
// CharacterCreationProfessionPage::Refresh's own already-cited
|
||||||
|
// UpdateAttributeValues formulas (Health=Endurance/2, Stamina=
|
||||||
|
// Endurance, Mana=Self) — see this class's own doc comment on why
|
||||||
|
// that citation is used here instead of this page's own
|
||||||
|
// decompiler-ambiguous GetAttribute(2)/GetAttribute(2) pair.
|
||||||
|
AddPair(pairTemplate, "Health", a.Endurance / 2);
|
||||||
|
AddPair(pairTemplate, "Stamina", a.Endurance);
|
||||||
|
AddPair(pairTemplate, "Mana", a.Self);
|
||||||
|
AddPair(pairTemplate, "Skill Credits", snapshot.RemainingSkillCredits);
|
||||||
|
|
||||||
|
AddSkillBucket(headerTemplate, lineTemplate, view, "Specialized Skills", ChargenSkillAdvancementClass.Specialized);
|
||||||
|
AddSkillBucket(headerTemplate, lineTemplate, view, "Trained Skills", ChargenSkillAdvancementClass.Trained);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddLine(UiTemplateListEntry template, string text)
|
||||||
|
{
|
||||||
|
if (ResolveTemplateChild(template, SingleLineTextId) is { } child)
|
||||||
|
SetLine(child, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddHeader(UiTemplateListEntry template, string text)
|
||||||
|
{
|
||||||
|
if (ResolveTemplateChild(template, HeaderTextId) is { } child)
|
||||||
|
SetLine(child, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddPair(UiTemplateListEntry template, string key, int value)
|
||||||
|
{
|
||||||
|
UiElement? row = ResolveTemplateRow(template);
|
||||||
|
if (row is null)
|
||||||
|
return;
|
||||||
|
if (UiElement.FindDescendant(row, KeyTextId) is UiText keyText)
|
||||||
|
SetLine(keyText, key);
|
||||||
|
if (UiElement.FindDescendant(row, ValueTextId) is UiText valueText)
|
||||||
|
SetLine(valueText, value.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetLine(UiText text, string content) =>
|
||||||
|
text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)];
|
||||||
|
|
||||||
|
private UiElement? ResolveTemplateRow(UiTemplateListEntry template)
|
||||||
|
{
|
||||||
|
if (_list is null || _list.TemplateResolver is null)
|
||||||
|
return null;
|
||||||
|
UiElement? row = _list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId);
|
||||||
|
if (row is null)
|
||||||
|
return null;
|
||||||
|
_list.AddPrebuiltRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UiText? ResolveTemplateChild(UiTemplateListEntry template, uint childId)
|
||||||
|
{
|
||||||
|
UiElement? row = ResolveTemplateRow(template);
|
||||||
|
return row is null ? null : UiElement.FindDescendant(row, childId) as UiText;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSkillBucket(
|
||||||
|
UiTemplateListEntry headerTemplate,
|
||||||
|
UiTemplateListEntry lineTemplate,
|
||||||
|
IRuntimeCharacterCreationView view,
|
||||||
|
string header,
|
||||||
|
ChargenSkillAdvancementClass targetClass)
|
||||||
|
{
|
||||||
|
bool any = false;
|
||||||
|
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
|
||||||
|
{
|
||||||
|
if (view.GetSkillLevel(skillId) != targetClass)
|
||||||
|
continue;
|
||||||
|
if (!any)
|
||||||
|
{
|
||||||
|
AddHeader(headerTemplate, header);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
AddLine(lineTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ProfessionName(ChargenHeritageOptions heritage, uint template) =>
|
||||||
|
template != RuntimeCharacterCreationSnapshot.TemplateUnset
|
||||||
|
&& template < (uint)heritage.Templates.Count
|
||||||
|
? heritage.Templates[(int)template].Name
|
||||||
|
: "None";
|
||||||
|
|
||||||
|
private static string GenderName(ChargenHeritageOptions heritage, uint genderKey) =>
|
||||||
|
heritage.GendersByKey.TryGetValue((int)genderKey, out ChargenGenderOptions? gender)
|
||||||
|
? gender.Name
|
||||||
|
: "None";
|
||||||
|
|
||||||
|
private static string StarterAreaName(ChargenOptions options, int startArea) =>
|
||||||
|
startArea >= 0 && startArea < options.StarterAreas.Count
|
||||||
|
? options.StarterAreas[startArea].Name
|
||||||
|
: "None";
|
||||||
|
|
||||||
|
// ── Preview (own gmCG3DView — InitializePage @0x0047bbf0, camera set +
|
||||||
|
// ── SetPlayerHeading(180) + StartAnimation, an idle-animated view) ───
|
||||||
|
|
||||||
|
private void RebuildPreview(
|
||||||
|
IRuntimeCharacterCreationView view,
|
||||||
|
RuntimeCharacterCreationSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (PreviewControl is null
|
||||||
|
|| snapshot.HeritageId == 0u
|
||||||
|
|| snapshot.GenderKey == 0u)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RuntimeCharacterCreationAppearance a = snapshot.Appearance;
|
||||||
|
var selection = new ChargenAppearanceSelection(
|
||||||
|
a.EyesStrip, a.NoseStrip, a.MouthStrip,
|
||||||
|
a.HairStyle, a.HairColor, a.EyeColor,
|
||||||
|
a.HeadgearStyle, a.HeadgearColor,
|
||||||
|
a.ShirtStyle, a.ShirtColor,
|
||||||
|
a.TrousersStyle, a.TrousersColor,
|
||||||
|
a.FootwearStyle, a.FootwearColor,
|
||||||
|
a.SkinShade, a.HairShade, a.HeadgearShade,
|
||||||
|
a.ShirtShade, a.TrousersShade, a.FootwearShade);
|
||||||
|
|
||||||
|
PreviewControl.Rebuild(view.Options, snapshot.HeritageId, (int)snapshot.GenderKey, selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_disposed = true;
|
||||||
|
if (_nameField is not null)
|
||||||
|
{
|
||||||
|
_nameField.OnFocusLost = null;
|
||||||
|
_nameField.OnSubmit = null;
|
||||||
|
}
|
||||||
|
if (_nameTooLongDialogContext != 0u)
|
||||||
|
{
|
||||||
|
uint closing = _nameTooLongDialogContext;
|
||||||
|
_nameTooLongDialogContext = 0u;
|
||||||
|
_dialogs.CloseDialog(closing);
|
||||||
|
}
|
||||||
|
_list?.Flush();
|
||||||
|
// PreviewControl is owned by the composition root (disposed with
|
||||||
|
// the leased ChargenPreviewRenderer) — just drop the reference.
|
||||||
|
PreviewControl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.Core.CharGen;
|
using AcDream.Core.CharGen;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
|
|
@ -43,6 +44,23 @@ public sealed record CharacterCreationRuntimeBindings(
|
||||||
/// <see langword="null"/> degrades to the heritage's own DAT
|
/// <see langword="null"/> degrades to the heritage's own DAT
|
||||||
/// <c>Name</c> field instead of the full composed copy.</summary>
|
/// <c>Name</c> field instead of the full composed copy.</summary>
|
||||||
Func<string, string?>? ResolveText = null,
|
Func<string, string?>? ResolveText = null,
|
||||||
|
/// <summary>Campaign CC slice CC5: the Summary page's name field
|
||||||
|
/// commit (<c>gmCGSummaryPage::ListenToElementMessage</c>'s
|
||||||
|
/// <c>CharGenState::SetName</c> call).</summary>
|
||||||
|
Func<string, RuntimeCommandResult>? SetName = null,
|
||||||
|
/// <summary>CC5: dismisses a surfaced <c>0xF643</c> rejection after its
|
||||||
|
/// dialog closes (<c>RuntimeCharacterCreationState.TryAcknowledgeRejection</c>).</summary>
|
||||||
|
Func<RuntimeCommandResult>? AcknowledgeRejection = null,
|
||||||
|
/// <summary>CC5: the screen-open roll
|
||||||
|
/// (<c>gmCharGenMainUI</c>'s ctor-time <c>RandomizeCharacter</c> call)
|
||||||
|
/// and the Summary page's Random button.</summary>
|
||||||
|
Func<RuntimeCommandResult>? RandomizeCharacter = null,
|
||||||
|
/// <summary>CC5: the Appearance page's Random button on its Face
|
||||||
|
/// sub-tab.</summary>
|
||||||
|
Func<RuntimeCommandResult>? RandomizeAppearance = null,
|
||||||
|
/// <summary>CC5: the Appearance page's Random button on its Clothes
|
||||||
|
/// sub-tab.</summary>
|
||||||
|
Func<RuntimeCommandResult>? RandomizeClothing = null,
|
||||||
bool OpenOnStart = false);
|
bool OpenOnStart = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -106,7 +124,23 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
Summary = 6,
|
Summary = 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record DialogStrings(string ExitWarning);
|
internal sealed record DialogStrings(
|
||||||
|
string ExitWarning,
|
||||||
|
/// <summary>Campaign CC slice CC5: <c>ID_CharGen_NoNameWarning</c> —
|
||||||
|
/// <c>DoFinish</c>'s empty-name refusal dialog.</summary>
|
||||||
|
string NoNameWarning,
|
||||||
|
/// <summary>CC5: <c>ID_CharGen_CreditWarning</c> —
|
||||||
|
/// <c>MakeCreditWarningDialog</c>'s unspent-attribute-credits
|
||||||
|
/// confirmation.</summary>
|
||||||
|
string CreditWarning,
|
||||||
|
/// <summary>CC5: <c>ID_CharGen_RandomizeWarning</c> —
|
||||||
|
/// <c>MakeRandomizeWarningDialog</c>'s Summary-page Random
|
||||||
|
/// confirmation.</summary>
|
||||||
|
string RandomizeWarning,
|
||||||
|
/// <summary>CC5: <c>ID_CharGen_NameTooLong</c> —
|
||||||
|
/// <c>gmCGSummaryPage::DoNameLimitDialog</c>'s name-field-too-long
|
||||||
|
/// notice.</summary>
|
||||||
|
string NameTooLong);
|
||||||
|
|
||||||
private readonly UiRoot _host;
|
private readonly UiRoot _host;
|
||||||
private readonly ImportedLayout _layout;
|
private readonly ImportedLayout _layout;
|
||||||
|
|
@ -138,6 +172,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
private readonly CharacterCreationSkillsPage _skillsPage;
|
private readonly CharacterCreationSkillsPage _skillsPage;
|
||||||
private readonly CharacterCreationTownPage _townPage;
|
private readonly CharacterCreationTownPage _townPage;
|
||||||
private readonly CharacterCreationAppearancePage _appearancePage;
|
private readonly CharacterCreationAppearancePage _appearancePage;
|
||||||
|
private readonly CharacterCreationSummaryPage _summaryPage;
|
||||||
|
|
||||||
private Vector2 _authoredCanvas;
|
private Vector2 _authoredCanvas;
|
||||||
private RuntimeGenerationToken _lastGeneration;
|
private RuntimeGenerationToken _lastGeneration;
|
||||||
|
|
@ -147,6 +182,13 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
private bool _isOpen;
|
private bool _isOpen;
|
||||||
private bool _openOnStartConsumed;
|
private bool _openOnStartConsumed;
|
||||||
private uint _exitDialogContext;
|
private uint _exitDialogContext;
|
||||||
|
// Campaign CC slice CC5: gmCharGenMainUI's own m_uiCreditWarningContext/
|
||||||
|
// m_uiRandomizeWarningContext (0x004e8870/0x004e8a90) — same
|
||||||
|
// one-outstanding-dialog-at-a-time guard shape as _exitDialogContext.
|
||||||
|
private uint _creditWarningDialogContext;
|
||||||
|
private uint _randomizeWarningDialogContext;
|
||||||
|
private uint _noNameWarningDialogContext;
|
||||||
|
private RuntimeCharacterCreationRejection? _lastShownRejection;
|
||||||
private bool _suppressDialogCallbacks;
|
private bool _suppressDialogCallbacks;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
|
@ -227,18 +269,18 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
_skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver);
|
_skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver);
|
||||||
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
|
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
|
||||||
_appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings);
|
_appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings);
|
||||||
|
_summaryPage = new CharacterCreationSummaryPage(
|
||||||
|
summaryPageRoot, bindings, dialogs, strings.NameTooLong);
|
||||||
|
|
||||||
// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450.
|
// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450.
|
||||||
_back.OnClick = OnBack;
|
_back.OnClick = OnBack;
|
||||||
_next.OnClick = OnNext;
|
_next.OnClick = OnNext;
|
||||||
// Finish (0x100003c8) stays ghosted this round: Summary
|
// Finish (0x100003c8): retail enables it on Summary only
|
||||||
// (0x100003d6) is CC5's placeholder, and DoFinish's real gate
|
// (ListenToElementMessage's case 0x100003c8 no-ops unless
|
||||||
// sequence lives in RuntimeCharacterCreationState.TryBeginFinish —
|
// m_eProgressState == ECG_SUMMARY @ 0x004e956f) — ApplyProgressState
|
||||||
// wiring the button here without a Summary page to confirm/collect
|
// gates _finish.Enabled the same way. OnFinish itself re-checks the
|
||||||
// the name would let a click reach the wire with an empty name and
|
// current page defensively (mirroring that same retail guard).
|
||||||
// silently refuse. No OnClick handler; _finish.Enabled stays false
|
_finish.OnClick = OnFinish;
|
||||||
// (see ApplyProgressState).
|
|
||||||
_finish.OnClick = null;
|
|
||||||
// Help (0x100003c9) is not handled in gmCharGenMainUI's own
|
// Help (0x100003c9) is not handled in gmCharGenMainUI's own
|
||||||
// ListenToElementMessage switch (case 0x100003c9 falls straight
|
// ListenToElementMessage switch (case 0x100003c9 falls straight
|
||||||
// through to the base UIFramework handler) — retail has no custom
|
// through to the base UIFramework handler) — retail has no custom
|
||||||
|
|
@ -280,6 +322,26 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
/// paperdoll's own outer-inventory-frame gate.</summary>
|
/// paperdoll's own outer-inventory-frame gate.</summary>
|
||||||
internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible;
|
internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible;
|
||||||
|
|
||||||
|
/// <summary>Campaign CC slice CC5: the authored Summary-page viewport
|
||||||
|
/// (<c>0x10000406</c>) — its OWN <c>gmCG3DView</c> instance, distinct
|
||||||
|
/// from the Appearance page's (see this class's own class doc on the
|
||||||
|
/// decomp citation).</summary>
|
||||||
|
internal UiViewport? SummaryViewport => _summaryPage.Viewport;
|
||||||
|
|
||||||
|
/// <summary>CC5: the Summary preview's late-bound control surface. No
|
||||||
|
/// zoom/rotate buttons bind against it — see
|
||||||
|
/// <see cref="AcDream.App.Rendering.RetailSummaryPreviewPageVisibility"/>'s
|
||||||
|
/// doc comment.</summary>
|
||||||
|
internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl
|
||||||
|
{
|
||||||
|
get => _summaryPage.PreviewControl;
|
||||||
|
set => _summaryPage.PreviewControl = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>CC5: same shape as <see cref="IsAppearancePageVisible"/>,
|
||||||
|
/// for the Summary page.</summary>
|
||||||
|
internal bool IsSummaryPageVisible => Root.Visible && _summaryPageRoot.Visible;
|
||||||
|
|
||||||
internal static CharacterCreationUiController? CreateDetached(
|
internal static CharacterCreationUiController? CreateDetached(
|
||||||
UiRoot host,
|
UiRoot host,
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
|
|
@ -404,6 +466,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
_skillsPage.Refresh(view, snapshot);
|
_skillsPage.Refresh(view, snapshot);
|
||||||
_townPage.Refresh(view, snapshot);
|
_townPage.Refresh(view, snapshot);
|
||||||
_appearancePage.Refresh(view, snapshot);
|
_appearancePage.Refresh(view, snapshot);
|
||||||
|
_summaryPage.Refresh(view, snapshot);
|
||||||
_lastGeneration = snapshot.Generation;
|
_lastGeneration = snapshot.Generation;
|
||||||
_lastRevision = snapshot.Revision;
|
_lastRevision = snapshot.Revision;
|
||||||
}
|
}
|
||||||
|
|
@ -426,9 +489,40 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
return;
|
return;
|
||||||
_isOpen = true;
|
_isOpen = true;
|
||||||
_host.DeclareFixedCanvas(this, _authoredCanvas);
|
_host.DeclareFixedCanvas(this, _authoredCanvas);
|
||||||
|
RollOpeningCharacter();
|
||||||
ApplyProgressState(Page.Heritage);
|
ApplyProgressState(Page.Heritage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign CC slice CC5: ports <c>gmCharGenMainUI</c>'s ctor-time roll
|
||||||
|
/// (<c>~0x004e81f5-0x004e8218</c>) — <c>CharGenState::RandomizeCharacter
|
||||||
|
/// (state, hasToD) @ 0x005c6d80</c> runs BEFORE any page constructs,
|
||||||
|
/// retiring AP-214's honest-blank deviation (retail's chargen screen is
|
||||||
|
/// never actually blank on open). Then reproduces
|
||||||
|
/// <c>gmCGAppearancePage::InitializePage</c>'s own gender-read-then-FLIP
|
||||||
|
/// (<c>~0x004802da-0x00480303</c>, decomp-confirmed:
|
||||||
|
/// <c>mGender==1 -> SetGender(2)</c>, <c>mGender==2 -> SetGender(1)</c>) —
|
||||||
|
/// a genuine, always-firing retail quirk that runs immediately AFTER
|
||||||
|
/// <c>RandomizeCharacter</c> already assigned a real (non-zero) gender.
|
||||||
|
/// Retail's whole UI tree (every page, including Appearance) is
|
||||||
|
/// reconstructed fresh each time the chargen screen opens, so the flip
|
||||||
|
/// fires once per visit there; acdream's pages are built once at mount
|
||||||
|
/// time and only toggle visibility, so <see cref="Open"/> — the closest
|
||||||
|
/// analogue to "runs once per screen-open" this architecture has — is
|
||||||
|
/// where both the roll and the flip belong.
|
||||||
|
/// </summary>
|
||||||
|
private void RollOpeningCharacter()
|
||||||
|
{
|
||||||
|
if (_bindings.RandomizeCharacter?.Invoke().Status != RuntimeCommandStatus.Accepted)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint gender = _bindings.View()?.Snapshot.GenderKey ?? 0u;
|
||||||
|
if (gender == 1u)
|
||||||
|
_bindings.SelectGender(2u);
|
||||||
|
else if (gender == 2u)
|
||||||
|
_bindings.SelectGender(1u);
|
||||||
|
}
|
||||||
|
|
||||||
private void Close()
|
private void Close()
|
||||||
{
|
{
|
||||||
if (!_isOpen)
|
if (!_isOpen)
|
||||||
|
|
@ -472,6 +566,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
_skillsPage.Dispose();
|
_skillsPage.Dispose();
|
||||||
_townPage.Dispose();
|
_townPage.Dispose();
|
||||||
_appearancePage.Dispose();
|
_appearancePage.Dispose();
|
||||||
|
_summaryPage.Dispose();
|
||||||
_host.RemoveChild(Root);
|
_host.RemoveChild(Root);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -535,10 +630,16 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town
|
// gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town
|
||||||
// are ported below; Skills' CharGenState::RandomizeSkills and the
|
// still use the AP-212 uniform-pick approximation (unchanged this
|
||||||
// Summary randomize-warning dialog have no CC3 primitive/page yet
|
// slice); Appearance now delegates to the page's own real
|
||||||
// this round — register AP-212 covers both gaps, and _random.Enabled
|
// RandomizeAppearance/RandomizeClothing primitives (CC5); Skills'
|
||||||
// already keeps the control ghosted on those pages (ApplyProgressState).
|
// CharGenState::RandomizeSkills remains unported (AP-212, narrowed) —
|
||||||
|
// _random.Enabled already keeps the control ghosted there
|
||||||
|
// (ApplyProgressState). Summary goes through
|
||||||
|
// gmCharGenMainUI::MakeRandomizeWarningDialog @ 0x004e8a90 first —
|
||||||
|
// that dialog + its confirm-triggered RandomizeCharacter call are
|
||||||
|
// gmCharGenMainUI's OWN methods in retail (not gmCGSummaryPage's),
|
||||||
|
// so they live here on the master controller.
|
||||||
IRuntimeCharacterCreationView? view = _bindings.View();
|
IRuntimeCharacterCreationView? view = _bindings.View();
|
||||||
if (view is null)
|
if (view is null)
|
||||||
return;
|
return;
|
||||||
|
|
@ -552,12 +653,44 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
case Page.Profession:
|
case Page.Profession:
|
||||||
_professionPage.Randomize(snapshot);
|
_professionPage.Randomize(snapshot);
|
||||||
break;
|
break;
|
||||||
|
case Page.Appearance:
|
||||||
|
_appearancePage.Randomize();
|
||||||
|
break;
|
||||||
case Page.Town:
|
case Page.Town:
|
||||||
_townPage.Randomize(view);
|
_townPage.Randomize(view);
|
||||||
break;
|
break;
|
||||||
|
case Page.Summary:
|
||||||
|
ShowRandomizeWarningDialog();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ports <c>gmCharGenMainUI::MakeRandomizeWarningDialog @
|
||||||
|
/// 0x004e8a90</c> (<c>ID_CharGen_RandomizeWarning</c>) +
|
||||||
|
/// <c>CloseRandomizeWarningDialog @ 0x004e8400</c>'s own confirm arm
|
||||||
|
/// (<c>arg2 != 0 -> DoRandom(this)</c>, which on THIS second call
|
||||||
|
/// takes <c>DoRandom</c>'s Summary case directly — no re-entrant
|
||||||
|
/// warning, since the gate lives in the button-click dispatcher above,
|
||||||
|
/// not inside <c>DoRandom</c> itself).</summary>
|
||||||
|
private void ShowRandomizeWarningDialog()
|
||||||
|
{
|
||||||
|
// MakeRandomizeWarningDialog's own guard: a second click while the
|
||||||
|
// dialog is already open is a no-op.
|
||||||
|
if (_randomizeWarningDialogContext != 0u)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_randomizeWarningDialogContext = _dialogs.MakeConfirmation(
|
||||||
|
_strings.RandomizeWarning,
|
||||||
|
data =>
|
||||||
|
{
|
||||||
|
_randomizeWarningDialogContext = 0u;
|
||||||
|
if (_disposed || _suppressDialogCallbacks)
|
||||||
|
return;
|
||||||
|
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
|
||||||
|
_bindings.RandomizeCharacter?.Invoke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Page switching (gmCharGenMainUI::SetProgressState @ 0x004e7a10) ────
|
// ── Page switching (gmCharGenMainUI::SetProgressState @ 0x004e7a10) ────
|
||||||
|
|
||||||
private void ApplyProgressState(Page target)
|
private void ApplyProgressState(Page target)
|
||||||
|
|
@ -641,19 +774,14 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Random (0x100003cb): fix round F5 — retail's DoRandom @0x004e7d70
|
// Random (0x100003cb): CC5 ports RandomizeAppearance/RandomizeClothing
|
||||||
// case 3 fully ENABLES Random on Appearance (RandomizeClothing when
|
// (Appearance) and RandomizeCharacter (Summary), retiring both gaps
|
||||||
// m_eCurType == ECG_CHOICE_CLOTHES, else RandomizeAppearance); this
|
// AP-212 used to track for those two pages — only Skills'
|
||||||
// is NOT a placeholder gap the way the old comment claimed. The
|
// RandomizeSkills remains unported (AP-212, narrowed).
|
||||||
// disable here rests on the SAME unported-primitive gap AP-212
|
_random.Enabled = _currentPage is not Page.Skills;
|
||||||
// tracks for Skills (no RandomizeSkills) and Summary (no
|
// Finish (0x100003c8): retail enables it on Summary only
|
||||||
// RandomizeCharacter) — RandomizeAppearance/RandomizeClothing are
|
// (ListenToElementMessage's case 0x100003c8 no-ops off Summary).
|
||||||
// two more of AP-212's six named-but-unported primitives.
|
_finish.Enabled = _currentPage == Page.Summary;
|
||||||
_random.Enabled = _currentPage
|
|
||||||
is not (Page.Skills or Page.Appearance or Page.Summary);
|
|
||||||
// Finish stays ghosted regardless of page — Summary is a
|
|
||||||
// placeholder this round (see the ctor comment on _finish.OnClick).
|
|
||||||
_finish.Enabled = false;
|
|
||||||
|
|
||||||
_lastRevision = long.MinValue;
|
_lastRevision = long.MinValue;
|
||||||
Tick();
|
Tick();
|
||||||
|
|
@ -719,12 +847,130 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
// Else (including Lugian, 0x100005f1): no-op, matching retail.
|
// Else (including Lugian, 0x100005f1): no-op, matching retail.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Finish (gmCharGenMainUI::DoFinish @ 0x004E9170) ─────────────────
|
||||||
|
|
||||||
|
/// <summary>The Finish button's ordinary click — retail's <c>arg2 = 1</c>
|
||||||
|
/// call site (<c>0x004E9579</c>). Re-checks the current page defensively,
|
||||||
|
/// mirroring <c>ListenToElementMessage</c>'s own
|
||||||
|
/// <c>m_eProgressState != ECG_SUMMARY</c> no-op guard.</summary>
|
||||||
|
private void OnFinish()
|
||||||
|
{
|
||||||
|
if (_disposed || _currentPage != Page.Summary)
|
||||||
|
return;
|
||||||
|
TryFinish(confirmedUnspentCredits: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends via <see cref="CharacterCreationRuntimeBindings.Finish"/>
|
||||||
|
/// (which itself calls <c>RuntimeCharacterCreationState.TryBeginFinish</c>);
|
||||||
|
/// on a LOCAL refusal, surfaces retail's own dialog for the two refusal
|
||||||
|
/// reasons retail dialogs at all (<c>NoName</c> ->
|
||||||
|
/// <c>ID_CharGen_NoNameWarning</c>; <c>AttributeCreditsUnspent</c> ->
|
||||||
|
/// the credit-warning confirm, whose OWN confirm re-invokes this method
|
||||||
|
/// with <paramref name="confirmedUnspentCredits"/> — retail's
|
||||||
|
/// <c>arg2 == 0</c> call site, <c>0x004E98BB</c>). The remaining local
|
||||||
|
/// refusals (<c>HeritageOrGenderUnset</c>, <c>AlreadyPending</c>,
|
||||||
|
/// <c>RosterFull</c>) have no retail dialog citation — retail's own
|
||||||
|
/// <c>DoFinish</c> silently falls through to its final <c>return 0</c>
|
||||||
|
/// for an already-Pending double-click, and the other two are
|
||||||
|
/// acdream-only additions (register AP-223, AP-211) with the same
|
||||||
|
/// silent-refusal shape.
|
||||||
|
/// </summary>
|
||||||
|
private void TryFinish(bool confirmedUnspentCredits)
|
||||||
|
{
|
||||||
|
if (_bindings.Finish(confirmedUnspentCredits).Status != RuntimeCommandStatus.Rejected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
RuntimeCharacterCreationLocalRefusal refusal =
|
||||||
|
_bindings.View()?.Snapshot.LastLocalRefusal ?? default;
|
||||||
|
if (refusal.NoName)
|
||||||
|
ShowNoNameWarningDialog();
|
||||||
|
else if (refusal.AttributeCreditsUnspent)
|
||||||
|
ShowCreditWarningDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ports the empty-name half of <c>DoFinish</c>
|
||||||
|
/// (<c>ID_CharGen_NoNameWarning</c>, <c>@0x004e91dd</c>) — a plain
|
||||||
|
/// informational dialog, no confirm/cancel semantics.</summary>
|
||||||
|
private void ShowNoNameWarningDialog()
|
||||||
|
{
|
||||||
|
if (_noNameWarningDialogContext != 0u)
|
||||||
|
return;
|
||||||
|
_noNameWarningDialogContext = _dialogs.MakeMessage(
|
||||||
|
_strings.NoNameWarning,
|
||||||
|
data =>
|
||||||
|
{
|
||||||
|
_ = data;
|
||||||
|
_noNameWarningDialogContext = 0u;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ports <c>gmCharGenMainUI::MakeCreditWarningDialog @
|
||||||
|
/// 0x004e8870</c> (<c>ID_CharGen_CreditWarning</c>) — on confirm,
|
||||||
|
/// re-invokes <see cref="TryFinish"/> with
|
||||||
|
/// <c>confirmedUnspentCredits: true</c>, retail's <c>DoFinish(this, 0)</c>
|
||||||
|
/// call at <c>RecvNotice_CloseDialog @0x004e98bb</c>.</summary>
|
||||||
|
private void ShowCreditWarningDialog()
|
||||||
|
{
|
||||||
|
if (_creditWarningDialogContext != 0u)
|
||||||
|
return;
|
||||||
|
_creditWarningDialogContext = _dialogs.MakeConfirmation(
|
||||||
|
_strings.CreditWarning,
|
||||||
|
data =>
|
||||||
|
{
|
||||||
|
_creditWarningDialogContext = 0u;
|
||||||
|
if (_disposed || _suppressDialogCallbacks)
|
||||||
|
return;
|
||||||
|
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
|
||||||
|
TryFinish(confirmedUnspentCredits: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 0xF643 rejection dialogs (Handle_CharGenVerificationResponse @ ──
|
||||||
|
// ── 0x0055E8B0) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Ports the four rejection-dialog mappings from
|
||||||
|
/// <c>Handle_CharGenVerificationResponse</c>'s per-case switch (restated
|
||||||
|
/// on <see cref="RuntimeCharacterCreationRejection"/>'s own doc
|
||||||
|
/// comment); Pending/Undef never reach this method (CC3's
|
||||||
|
/// <c>ApplyCreationResponse</c> treats them as a silent state reset with
|
||||||
|
/// no <see cref="RuntimeCharacterCreationRejection"/> produced at all).
|
||||||
|
/// Dedups against the LAST rejection instance already shown so a
|
||||||
|
/// same-value re-check on a later <see cref="Tick"/> (this method runs
|
||||||
|
/// every tick, not just on revision change) doesn't reopen the dialog
|
||||||
|
/// the player already dismissed.</summary>
|
||||||
private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot)
|
private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot)
|
||||||
{
|
{
|
||||||
// Local-refusal / rejection surfacing is CC5's Summary-page job
|
RuntimeCharacterCreationRejection? rejection = snapshot.LastRejection;
|
||||||
// (the Finish gate only fires from that page). This round only
|
if (rejection is null)
|
||||||
// needs the exit-confirmation dialog reconciled against disposal.
|
{
|
||||||
_ = snapshot;
|
_lastShownRejection = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_lastShownRejection == rejection)
|
||||||
|
return;
|
||||||
|
_lastShownRejection = rejection;
|
||||||
|
|
||||||
|
string? key = rejection.Value.Code switch
|
||||||
|
{
|
||||||
|
CharGenVerificationResponse.Code.NameInUse => "ID_Character_Err_NameReserved",
|
||||||
|
CharGenVerificationResponse.Code.NameBanned => "ID_Character_Err_NameBanned",
|
||||||
|
CharGenVerificationResponse.Code.Corrupt
|
||||||
|
or CharGenVerificationResponse.Code.DatabaseDown => "ID_Character_Err_NameDBDown",
|
||||||
|
CharGenVerificationResponse.Code.AdminPrivilegeDenied => "ID_Character_Err_NameAdminDenied",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (key is null)
|
||||||
|
return;
|
||||||
|
string? message = _bindings.ResolveText?.Invoke(key);
|
||||||
|
if (message is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_dialogs.MakeMessage(message, data =>
|
||||||
|
{
|
||||||
|
_ = data;
|
||||||
|
_bindings.AcknowledgeRejection?.Invoke();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Deactivate()
|
private void Deactivate()
|
||||||
|
|
@ -750,6 +996,24 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
_exitDialogContext = 0u;
|
_exitDialogContext = 0u;
|
||||||
_dialogs.CloseDialog(closing);
|
_dialogs.CloseDialog(closing);
|
||||||
}
|
}
|
||||||
|
if (_creditWarningDialogContext != 0u)
|
||||||
|
{
|
||||||
|
uint closing = _creditWarningDialogContext;
|
||||||
|
_creditWarningDialogContext = 0u;
|
||||||
|
_dialogs.CloseDialog(closing);
|
||||||
|
}
|
||||||
|
if (_randomizeWarningDialogContext != 0u)
|
||||||
|
{
|
||||||
|
uint closing = _randomizeWarningDialogContext;
|
||||||
|
_randomizeWarningDialogContext = 0u;
|
||||||
|
_dialogs.CloseDialog(closing);
|
||||||
|
}
|
||||||
|
if (_noNameWarningDialogContext != 0u)
|
||||||
|
{
|
||||||
|
uint closing = _noNameWarningDialogContext;
|
||||||
|
_noNameWarningDialogContext = 0u;
|
||||||
|
_dialogs.CloseDialog(closing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -669,6 +669,34 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
internal bool IsChargenPreviewPageVisible =>
|
internal bool IsChargenPreviewPageVisible =>
|
||||||
CharacterCreationController?.IsAppearancePageVisible ?? false;
|
CharacterCreationController?.IsAppearancePageVisible ?? false;
|
||||||
|
|
||||||
|
/// <summary>Campaign CC slice CC5: the Summary page's OWN authored
|
||||||
|
/// viewport (<c>0x10000406</c>) — same one-shot GPU-composition
|
||||||
|
/// disposition as <see cref="ChargenPreviewViewportWidget"/> (see that
|
||||||
|
/// property's own doc comment; AP-221 covers both).</summary>
|
||||||
|
internal UiViewport? SummaryPreviewViewportWidget =>
|
||||||
|
CharacterCreationController?.SummaryViewport;
|
||||||
|
|
||||||
|
/// <summary>Campaign CC slice CC5: the Summary preview's late-bound
|
||||||
|
/// control surface. No zoom/rotate buttons bind against it (retail's
|
||||||
|
/// Summary page has none) — the composition root assigns it purely so
|
||||||
|
/// <see cref="AcDream.App.Rendering.ChargenPreviewController.Rebuild"/>
|
||||||
|
/// gets driven per-selection-change the same way the Appearance
|
||||||
|
/// preview's is.</summary>
|
||||||
|
internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl
|
||||||
|
{
|
||||||
|
get => CharacterCreationController?.SummaryPreviewControl;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (CharacterCreationController is { } controller)
|
||||||
|
controller.SummaryPreviewControl = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Campaign CC slice CC5: whether the Summary page
|
||||||
|
/// (specifically) is the one currently showing.</summary>
|
||||||
|
internal bool IsSummaryPreviewPageVisible =>
|
||||||
|
CharacterCreationController?.IsSummaryPageVisible ?? false;
|
||||||
|
|
||||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(bindings);
|
ArgumentNullException.ThrowIfNull(bindings);
|
||||||
|
|
@ -3977,14 +4005,38 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
string? exitWarning;
|
string? exitWarning;
|
||||||
|
string? noNameWarning;
|
||||||
|
string? creditWarning;
|
||||||
|
string? randomizeWarning;
|
||||||
|
string? nameTooLong;
|
||||||
lock (_bindings.Assets.DatLock)
|
lock (_bindings.Assets.DatLock)
|
||||||
{
|
{
|
||||||
exitWarning = ResolveCharacterManagementString(
|
exitWarning = ResolveCharacterManagementString(
|
||||||
strings,
|
strings,
|
||||||
stringTableId,
|
stringTableId,
|
||||||
"ID_CharGen_ExitWarning");
|
"ID_CharGen_ExitWarning");
|
||||||
|
noNameWarning = ResolveCharacterManagementString(
|
||||||
|
strings,
|
||||||
|
stringTableId,
|
||||||
|
"ID_CharGen_NoNameWarning");
|
||||||
|
creditWarning = ResolveCharacterManagementString(
|
||||||
|
strings,
|
||||||
|
stringTableId,
|
||||||
|
"ID_CharGen_CreditWarning");
|
||||||
|
randomizeWarning = ResolveCharacterManagementString(
|
||||||
|
strings,
|
||||||
|
stringTableId,
|
||||||
|
"ID_CharGen_RandomizeWarning");
|
||||||
|
nameTooLong = ResolveCharacterManagementString(
|
||||||
|
strings,
|
||||||
|
stringTableId,
|
||||||
|
"ID_CharGen_NameTooLong");
|
||||||
}
|
}
|
||||||
if (exitWarning is null)
|
if (exitWarning is null
|
||||||
|
|| noNameWarning is null
|
||||||
|
|| creditWarning is null
|
||||||
|
|| randomizeWarning is null
|
||||||
|
|| nameTooLong is null)
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
"[UI] character creation: required retail strings are unavailable.");
|
"[UI] character creation: required retail strings are unavailable.");
|
||||||
|
|
@ -4009,7 +4061,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
layoutId,
|
layoutId,
|
||||||
layout,
|
layout,
|
||||||
ResolveTemplate,
|
ResolveTemplate,
|
||||||
new CharacterCreationUiController.DialogStrings(exitWarning));
|
new CharacterCreationUiController.DialogStrings(
|
||||||
|
exitWarning, noNameWarning, creditWarning, randomizeWarning, nameTooLong));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MountItemCooldowns()
|
private void MountItemCooldowns()
|
||||||
|
|
|
||||||
|
|
@ -460,6 +460,30 @@ public interface IRuntimeCharacterCreationCommands
|
||||||
|
|
||||||
RuntimeCommandResult AcknowledgeRejection(
|
RuntimeCommandResult AcknowledgeRejection(
|
||||||
RuntimeGenerationToken expectedGeneration);
|
RuntimeGenerationToken expectedGeneration);
|
||||||
|
|
||||||
|
// ── Campaign CC slice CC5: RandomizeCharacter port ──────────────────
|
||||||
|
|
||||||
|
/// <summary>Retail's ctor-time <c>CharGenState::RandomizeCharacter</c>
|
||||||
|
/// roll (mirrored at the App layer's screen-open edge) and the Summary
|
||||||
|
/// page's Random button (<c>gmCharGenMainUI::DoRandom</c> case 5, behind
|
||||||
|
/// the caller's own <c>ID_CharGen_RandomizeWarning</c> confirmation) —
|
||||||
|
/// see <see cref="Session.RuntimeCharacterCreationState.TryRandomizeCharacter"/>.</summary>
|
||||||
|
RuntimeCommandResult RandomizeCharacter(
|
||||||
|
RuntimeGenerationToken expectedGeneration);
|
||||||
|
|
||||||
|
/// <summary>The Appearance page's Random button while its Face sub-tab
|
||||||
|
/// is showing (<c>gmCharGenMainUI::DoRandom</c> case 3's <c>else</c>
|
||||||
|
/// arm) — see
|
||||||
|
/// <see cref="Session.RuntimeCharacterCreationState.TryRandomizeAppearance"/>.</summary>
|
||||||
|
RuntimeCommandResult RandomizeAppearance(
|
||||||
|
RuntimeGenerationToken expectedGeneration);
|
||||||
|
|
||||||
|
/// <summary>The Appearance page's Random button while its Clothes
|
||||||
|
/// sub-tab is showing (<c>gmCharGenMainUI::DoRandom</c> case 3's
|
||||||
|
/// <c>RandomizeClothing(state, 1)</c> arm) — see
|
||||||
|
/// <see cref="Session.RuntimeCharacterCreationState.TryRandomizeClothing"/>.</summary>
|
||||||
|
RuntimeCommandResult RandomizeClothing(
|
||||||
|
RuntimeGenerationToken expectedGeneration);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IGameRuntimeCommands
|
public interface IGameRuntimeCommands
|
||||||
|
|
|
||||||
|
|
@ -1657,6 +1657,45 @@ public sealed class LiveSessionController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RuntimeCommandResult RandomizeCharacter(
|
||||||
|
RuntimeGenerationToken expectedGeneration)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
|
||||||
|
if (gate != RuntimeCommandStatus.Accepted)
|
||||||
|
return CharacterCreationResult(gate);
|
||||||
|
return CharacterCreationResult(
|
||||||
|
CharacterCreationState.TryRandomizeCharacter());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuntimeCommandResult RandomizeAppearance(
|
||||||
|
RuntimeGenerationToken expectedGeneration)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
|
||||||
|
if (gate != RuntimeCommandStatus.Accepted)
|
||||||
|
return CharacterCreationResult(gate);
|
||||||
|
return CharacterCreationResult(
|
||||||
|
CharacterCreationState.TryRandomizeAppearance());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuntimeCommandResult RandomizeClothing(
|
||||||
|
RuntimeGenerationToken expectedGeneration)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
|
||||||
|
if (gate != RuntimeCommandStatus.Accepted)
|
||||||
|
return CharacterCreationResult(gate);
|
||||||
|
return CharacterCreationResult(
|
||||||
|
CharacterCreationState.TryRandomizeClothing());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private RuntimeCommandStatus ValidateCharacterCreationCommand(
|
private RuntimeCommandStatus ValidateCharacterCreationCommand(
|
||||||
RuntimeGenerationToken expectedGeneration)
|
RuntimeGenerationToken expectedGeneration)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -124,16 +124,32 @@ public readonly record struct RuntimeCharacterCreationAppearance(
|
||||||
/// evaluates before a Finish click is allowed to reach the wire, plus the
|
/// evaluates before a Finish click is allowed to reach the wire, plus the
|
||||||
/// campaign's client-side slot cap (risk item 3 — retail's UI, not
|
/// campaign's client-side slot cap (risk item 3 — retail's UI, not
|
||||||
/// <c>DoFinish</c> itself, refuses when the roster is already full versus
|
/// <c>DoFinish</c> itself, refuses when the roster is already full versus
|
||||||
/// <c>CharacterList.slotCount</c>; ACE never checks this server-side).
|
/// <c>CharacterList.slotCount</c>; ACE never checks this server-side), plus
|
||||||
|
/// (Campaign CC slice CC5, the CC6b-MOUNT review's F12 amendment)
|
||||||
|
/// <see cref="HeritageOrGenderUnset"/> — an acdream-ONLY addition with no
|
||||||
|
/// direct <c>DoFinish</c> citation (register AP-223): retail's own
|
||||||
|
/// <c>DoFinish</c> never checks heritage/gender because it can't reach a
|
||||||
|
/// state where either is unset — <c>gmCharGenMainUI</c>'s constructor calls
|
||||||
|
/// <c>CharGenState::RandomizeCharacter</c> before any page (including
|
||||||
|
/// Summary/Finish) exists, so a real heritage+gender selection is an
|
||||||
|
/// ARCHITECTURAL guarantee by the time Finish is clickable at all. Once
|
||||||
|
/// <see cref="RuntimeCharacterCreationState.TryRandomizeCharacter"/> is
|
||||||
|
/// wired at the App layer's screen-open edge (mirroring that same ctor
|
||||||
|
/// call), this refusal is normally unreachable through the ordinary UI —
|
||||||
|
/// it exists as a defensive backstop for any caller (a headless bot, a
|
||||||
|
/// future direct command) that can reach <c>Finish</c> without that
|
||||||
|
/// open-edge roll ever having run.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct RuntimeCharacterCreationLocalRefusal(
|
public readonly record struct RuntimeCharacterCreationLocalRefusal(
|
||||||
bool NoName,
|
bool NoName,
|
||||||
bool AttributeCreditsUnspent,
|
bool AttributeCreditsUnspent,
|
||||||
bool AlreadyPending,
|
bool AlreadyPending,
|
||||||
bool RosterFull)
|
bool RosterFull,
|
||||||
|
bool HeritageOrGenderUnset = false)
|
||||||
{
|
{
|
||||||
public bool Any =>
|
public bool Any =>
|
||||||
NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull;
|
NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull
|
||||||
|
|| HeritageOrGenderUnset;
|
||||||
|
|
||||||
public static RuntimeCharacterCreationLocalRefusal None { get; } = default;
|
public static RuntimeCharacterCreationLocalRefusal None { get; } = default;
|
||||||
}
|
}
|
||||||
|
|
@ -487,6 +503,21 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
if (_disposed || !_active)
|
if (_disposed || !_active)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
SetHeritageGroupLocked(heritageId, heritage);
|
||||||
|
_revision++;
|
||||||
|
}
|
||||||
|
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The locked body of <c>CharGenState::SetHeritageGroup @ 0x005C67A0</c> —
|
||||||
|
/// factored out of <see cref="TrySelectHeritage"/> (Campaign CC slice
|
||||||
|
/// CC5) so <see cref="RandomizeCharacterLocked"/>'s own heritage roll
|
||||||
|
/// can reuse it without re-entering <see cref="_gate"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void SetHeritageGroupLocked(uint heritageId, ChargenHeritageOptions heritage)
|
||||||
|
{
|
||||||
_heritageId = heritageId;
|
_heritageId = heritageId;
|
||||||
_totalAttributeCredits = heritage.AttributeCredits;
|
_totalAttributeCredits = heritage.AttributeCredits;
|
||||||
_totalSkillCredits = heritage.SkillCredits;
|
_totalSkillCredits = heritage.SkillCredits;
|
||||||
|
|
@ -502,10 +533,6 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
// heritage switch that leaves stale skill picks over-budget.
|
// heritage switch that leaves stale skill picks over-budget.
|
||||||
if (RecomputeSkillSpendLocked(heritage) < 0)
|
if (RecomputeSkillSpendLocked(heritage) < 0)
|
||||||
ResetSkillLevelsLocked(heritage);
|
ResetSkillLevelsLocked(heritage);
|
||||||
_revision++;
|
|
||||||
}
|
|
||||||
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ports <c>CharGenState::SetGender @ 0x005C64A0</c>: clamps
|
/// <summary>Ports <c>CharGenState::SetGender @ 0x005C64A0</c>: clamps
|
||||||
|
|
@ -527,14 +554,23 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_genderKey = genderKey;
|
SetGenderLocked(genderKey);
|
||||||
ConstrainAppearanceByGenderLocked();
|
|
||||||
_revision++;
|
_revision++;
|
||||||
}
|
}
|
||||||
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The locked body of <c>CharGenState::SetGender @
|
||||||
|
/// 0x005C64A0</c> — factored out of <see cref="TrySelectGender"/>
|
||||||
|
/// (Campaign CC slice CC5) so <see cref="RandomizeCharacterLocked"/>'s
|
||||||
|
/// own gender roll can reuse it.</summary>
|
||||||
|
private void SetGenderLocked(uint genderKey)
|
||||||
|
{
|
||||||
|
_genderKey = genderKey;
|
||||||
|
ConstrainAppearanceByGenderLocked();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ports the seven Profession-page buttons, each of which calls
|
/// Ports the seven Profession-page buttons, each of which calls
|
||||||
/// <c>CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60</c> — template
|
/// <c>CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60</c> — template
|
||||||
|
|
@ -1164,6 +1200,362 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
: value;
|
: value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Randomize (Campaign CC slice CC5) ───────────────────────────────
|
||||||
|
// Ports CharGenState::RandomizeCharacter @ 0x005c6d80 and its six named
|
||||||
|
// sub-primitives (RandomizeHeritageGroup/RandomizeAppearance/
|
||||||
|
// RandomizeHeadgear/RandomizeShirt/RandomizeTrousers/RandomizeFootwear/
|
||||||
|
// RandomizeTemplate/RandomizeStartArea — register AP-212's own citation
|
||||||
|
// list). The RNG primitive both retail's own RandInt(int) and
|
||||||
|
// RandInt(int,int) overloads reduce to is decompiled verbatim at
|
||||||
|
// 0x00684400/0x00684420: RandInt(count) is a uniform pick in [0,count);
|
||||||
|
// RandInt(count,exclude) loops the same roll until it differs from
|
||||||
|
// exclude (a no-op when count<=1, matching retail's own early-return —
|
||||||
|
// ported as RandomizeIndexExcludingLocked below). CharGenState's own
|
||||||
|
// vtable (acclient.h's $A0F97670E669114D706A75D718F5A366 union —
|
||||||
|
// "GetRandomInt(this,int,int)"/"Grandom Int(this,int)") confirms
|
||||||
|
// RandomizeAppearance's vtable-indirected calls are this SAME RandInt
|
||||||
|
// pair, not a distinct algorithm.
|
||||||
|
|
||||||
|
/// <summary>Ports <c>Random::RollDice(int,int) @ 0x0042c5c0</c>: returns
|
||||||
|
/// <paramref name="min"/> unchanged when the two bounds are equal
|
||||||
|
/// (matching retail's own <c>arg2==arg1</c> fast path), otherwise a
|
||||||
|
/// uniform pick over the INCLUSIVE range
|
||||||
|
/// <c>[min(min,max), max(min,max)]</c>.</summary>
|
||||||
|
private int RollDiceLocked(int min, int max)
|
||||||
|
{
|
||||||
|
if (min == max)
|
||||||
|
return min;
|
||||||
|
int lo = Math.Min(min, max);
|
||||||
|
int hi = Math.Max(min, max);
|
||||||
|
return lo + _random.Next(hi - lo + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ports <c>RandInt(int,int) @ 0x00684420</c> exactly: for
|
||||||
|
/// <paramref name="count"/> <= 1 there is only one possible outcome,
|
||||||
|
/// so retail returns 0 immediately WITHOUT ever comparing against
|
||||||
|
/// <paramref name="exclude"/> (the guard that keeps the do/while loop
|
||||||
|
/// below from spinning forever); otherwise re-rolls uniformly in
|
||||||
|
/// <c>[0,count)</c> until the result differs from
|
||||||
|
/// <paramref name="exclude"/> — an <paramref name="exclude"/> outside
|
||||||
|
/// <c>[0,count)</c> (e.g. <see cref="RuntimeCharacterCreationAppearance.Unset"/>
|
||||||
|
/// on a freshly-<see cref="ClearSessionState"/>'d field) can never match,
|
||||||
|
/// so the loop always exits on its first iteration and this degrades to
|
||||||
|
/// a plain uniform pick.</summary>
|
||||||
|
private uint RandomizeIndexExcludingLocked(int count, uint exclude)
|
||||||
|
{
|
||||||
|
if (count <= 1)
|
||||||
|
return 0u;
|
||||||
|
uint result;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
result = (uint)_random.Next(count);
|
||||||
|
} while (result == exclude);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ports <c>CharGenState::RandomizeAppearance(this, 0) @
|
||||||
|
/// 0x005c4f10</c> — every real call site in the retail binary passes
|
||||||
|
/// <c>arg2 == 0</c> (an exhaustive grep of every <c>RandomizeAppearance</c>
|
||||||
|
/// call found none with <c>arg2 != 0</c>), so the <c>arg2 != 0</c> arm
|
||||||
|
/// (a hard-coded vtable-index-7 hair-style pick) is decompiled but dead
|
||||||
|
/// code and is not ported. Each field is only rolled when its list is
|
||||||
|
/// non-empty (retail's own per-field <c>if (count != 0)</c> guards);
|
||||||
|
/// <c>skinShade</c>/<c>hairShade</c> are <c>vtable->GetRandomReal()</c>
|
||||||
|
/// — the SAME <c>rand()*(1/32768)</c> uniform-[0,1) shade roll every
|
||||||
|
/// other Randomize* function below uses explicitly inline.</summary>
|
||||||
|
private void RandomizeAppearanceLocked()
|
||||||
|
{
|
||||||
|
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
|
||||||
|
return;
|
||||||
|
|
||||||
|
RuntimeCharacterCreationAppearance a = _appearance;
|
||||||
|
if (gender.EyeStrips.Count > 0)
|
||||||
|
a = a with { EyesStrip = RandomizeIndexExcludingLocked(gender.EyeStrips.Count, a.EyesStrip) };
|
||||||
|
if (gender.NoseStrips.Count > 0)
|
||||||
|
a = a with { NoseStrip = RandomizeIndexExcludingLocked(gender.NoseStrips.Count, a.NoseStrip) };
|
||||||
|
if (gender.MouthStrips.Count > 0)
|
||||||
|
a = a with { MouthStrip = RandomizeIndexExcludingLocked(gender.MouthStrips.Count, a.MouthStrip) };
|
||||||
|
a = a with { SkinShade = _random.NextDouble(), HairShade = _random.NextDouble() };
|
||||||
|
if (gender.HairColors.Count > 0)
|
||||||
|
a = a with { HairColor = RandomizeIndexExcludingLocked(gender.HairColors.Count, a.HairColor) };
|
||||||
|
if (gender.EyeColors.Count > 0)
|
||||||
|
a = a with { EyeColor = RandomizeIndexExcludingLocked(gender.EyeColors.Count, a.EyeColor) };
|
||||||
|
if (gender.HairStyles.Count > 0)
|
||||||
|
a = a with { HairStyle = RandomizeIndexExcludingLocked(gender.HairStyles.Count, a.HairStyle) };
|
||||||
|
_appearance = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ports <c>CharGenState::RandomizeHeadgear(this, arg2) @ 0x005c5e10</c>.
|
||||||
|
/// Headgear alone gets the <c>count+1</c>-position Unset ring
|
||||||
|
/// (<c>CharacterCreationAppearancePage.CycleIndex</c>'s own already-cited
|
||||||
|
/// sibling finding): <paramref name="excludeCurrent"/> false (every
|
||||||
|
/// <c>RandomizeCharacter</c> call site, <c>arg2==0</c>) rolls a plain
|
||||||
|
/// uniform <c>RandInt(count+1)</c>; true (<c>RandomizeClothing(state,1)</c>'s
|
||||||
|
/// own Appearance-page Random-button case) excludes the current style
|
||||||
|
/// via <c>RandInt(count+1, headgearStyle+1)</c> — the <c>+1</c>
|
||||||
|
/// reindexes Unset (retail's signed <c>-1</c>) to <c>0</c> so the
|
||||||
|
/// exclude comparison stays in <c>[0,count]</c>. Color uses the SAME
|
||||||
|
/// <see cref="AppearanceSlotCountLocked"/> shared-<c>ClothingColors</c>
|
||||||
|
/// approximation (register AP-208) every other clothing slot's color
|
||||||
|
/// count already uses, not retail's own per-heritage
|
||||||
|
/// <c>numHeadgearColors</c> field acdream's model does not carry.
|
||||||
|
/// </summary>
|
||||||
|
private void RandomizeHeadgearLocked(bool excludeCurrent)
|
||||||
|
{
|
||||||
|
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
|
||||||
|
return;
|
||||||
|
|
||||||
|
int styleCount = gender.Headgears.Count;
|
||||||
|
if (styleCount > 0)
|
||||||
|
{
|
||||||
|
uint current = _appearance.HeadgearStyle;
|
||||||
|
int currentPlusOne = current == RuntimeCharacterCreationAppearance.Unset
|
||||||
|
? 0
|
||||||
|
: (int)current + 1;
|
||||||
|
int rolled = excludeCurrent
|
||||||
|
? (int)RandomizeIndexExcludingLocked(styleCount + 1, (uint)currentPlusOne)
|
||||||
|
: _random.Next(styleCount + 1);
|
||||||
|
uint newStyle = rolled == 0 ? RuntimeCharacterCreationAppearance.Unset : (uint)(rolled - 1);
|
||||||
|
_appearance = _appearance with { HeadgearStyle = newStyle };
|
||||||
|
}
|
||||||
|
|
||||||
|
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.HeadgearColor, gender);
|
||||||
|
if (colorCount > 0)
|
||||||
|
{
|
||||||
|
_appearance = _appearance with
|
||||||
|
{
|
||||||
|
HeadgearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.HeadgearColor),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_appearance = _appearance with { HeadgearShade = _random.NextDouble() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ports <c>CharGenState::RandomizeShirt @ 0x005c5ef0</c> —
|
||||||
|
/// unlike headgear, retail's shirt/trousers/footwear randomizers take no
|
||||||
|
/// <c>arg2</c> and always exclude the current style/color.</summary>
|
||||||
|
private void RandomizeShirtLocked()
|
||||||
|
{
|
||||||
|
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
|
||||||
|
return;
|
||||||
|
int styleCount = gender.Shirts.Count;
|
||||||
|
if (styleCount > 0)
|
||||||
|
{
|
||||||
|
_appearance = _appearance with
|
||||||
|
{
|
||||||
|
ShirtStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.ShirtStyle),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.ShirtColor, gender);
|
||||||
|
if (colorCount > 0)
|
||||||
|
{
|
||||||
|
_appearance = _appearance with
|
||||||
|
{
|
||||||
|
ShirtColor = RandomizeIndexExcludingLocked(colorCount, _appearance.ShirtColor),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_appearance = _appearance with { ShirtShade = _random.NextDouble() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ports <c>CharGenState::RandomizeTrousers @ 0x005c5fb0</c>.</summary>
|
||||||
|
private void RandomizeTrousersLocked()
|
||||||
|
{
|
||||||
|
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
|
||||||
|
return;
|
||||||
|
int styleCount = gender.Pants.Count;
|
||||||
|
if (styleCount > 0)
|
||||||
|
{
|
||||||
|
_appearance = _appearance with
|
||||||
|
{
|
||||||
|
TrousersStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.TrousersStyle),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.TrousersColor, gender);
|
||||||
|
if (colorCount > 0)
|
||||||
|
{
|
||||||
|
_appearance = _appearance with
|
||||||
|
{
|
||||||
|
TrousersColor = RandomizeIndexExcludingLocked(colorCount, _appearance.TrousersColor),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_appearance = _appearance with { TrousersShade = _random.NextDouble() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ports <c>CharGenState::RandomizeFootwear @ 0x005c6070</c>.</summary>
|
||||||
|
private void RandomizeFootwearLocked()
|
||||||
|
{
|
||||||
|
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
|
||||||
|
return;
|
||||||
|
int styleCount = gender.Footwear.Count;
|
||||||
|
if (styleCount > 0)
|
||||||
|
{
|
||||||
|
_appearance = _appearance with
|
||||||
|
{
|
||||||
|
FootwearStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.FootwearStyle),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.FootwearColor, gender);
|
||||||
|
if (colorCount > 0)
|
||||||
|
{
|
||||||
|
_appearance = _appearance with
|
||||||
|
{
|
||||||
|
FootwearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.FootwearColor),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_appearance = _appearance with { FootwearShade = _random.NextDouble() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ports <c>CharGenState::RandomizeClothing(this, arg2) @
|
||||||
|
/// 0x005c6770</c>: headgear (with <paramref name="excludeCurrent"/>
|
||||||
|
/// forwarded), then shirt/trousers/footwear (always exclude-current,
|
||||||
|
/// they take no <c>arg2</c>).</summary>
|
||||||
|
private void RandomizeClothingLocked(bool excludeCurrent)
|
||||||
|
{
|
||||||
|
RandomizeHeadgearLocked(excludeCurrent);
|
||||||
|
RandomizeShirtLocked();
|
||||||
|
RandomizeTrousersLocked();
|
||||||
|
RandomizeFootwearLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ports <c>CharGenState::RandomizeTemplate @ 0x005c6500</c>. The two
|
||||||
|
/// Olthoi heritages force template 0 unconditionally
|
||||||
|
/// (<c>this->template_ = 1; ApplyTemplate(this);</c> in retail — but
|
||||||
|
/// <c>ApplyTemplate</c>'s own Olthoi branch immediately re-forces
|
||||||
|
/// <c>template_ = 0</c> regardless, so the intermediate write to 1 is
|
||||||
|
/// observably a no-op; this port skips straight to
|
||||||
|
/// <see cref="ApplyTemplateLocked"/>, which already carries that force).
|
||||||
|
/// Otherwise, when the heritage has more than one template (Custom plus
|
||||||
|
/// at least one preset), picks <c>RandInt(count-1, template_-1) + 1</c> —
|
||||||
|
/// a uniform pick over indices <c>[1, count-1]</c> (retail's own
|
||||||
|
/// preset templates, NEVER index 0/Custom) excluding the CURRENT
|
||||||
|
/// template (offset by <c>-1</c> to align with the shifted range; an
|
||||||
|
/// Unset/<c>0xFFFFFFFF</c> current value wraps far outside <c>[0,count-1)</c>
|
||||||
|
/// and can never match, so a fresh roll off a Reset state is
|
||||||
|
/// unconstrained).
|
||||||
|
/// </summary>
|
||||||
|
private void RandomizeTemplateLocked()
|
||||||
|
{
|
||||||
|
if (_heritageId == 0 || _genderKey == 0)
|
||||||
|
return;
|
||||||
|
if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_heritageId == (uint)ChargenHeritageGroup.Olthoi
|
||||||
|
|| _heritageId == (uint)ChargenHeritageGroup.OlthoiAcid)
|
||||||
|
{
|
||||||
|
ApplyTemplateLocked(heritage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = heritage.Templates.Count;
|
||||||
|
if (count <= 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint excludeShifted = unchecked(_template - 1u);
|
||||||
|
uint picked = RandomizeIndexExcludingLocked(count - 1, excludeShifted) + 1u;
|
||||||
|
_template = picked;
|
||||||
|
ApplyTemplateLocked(heritage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ports <c>CharGenState::RandomizeCharacter(this, hasToD) @
|
||||||
|
/// 0x005c6d80</c>: <see cref="ClearSessionState"/> (retail's own
|
||||||
|
/// <c>Reset()</c>), roll a heritage
|
||||||
|
/// (<c>SetHeritageGroup(RollDice(1, hasToD?4:3))</c> — heritage ids
|
||||||
|
/// 1..3/4 are the four HUMAN heritage groups (Aluvian/Gharu'ndim/Sho/
|
||||||
|
/// Viamontian, <see cref="ChargenHeritageGroup"/>'s own numbering); a
|
||||||
|
/// "random" character in retail is deliberately always human, never one
|
||||||
|
/// of the other nine heritages — a genuine retail quirk, not a porting
|
||||||
|
/// shortcut), roll a gender (<c>SetGender(RollDice(1,2))</c>), then
|
||||||
|
/// appearance/headgear/shirt/trousers/footwear/template/start-area, in
|
||||||
|
/// that exact order. acdream has no account/DLC-ownership signal (AD-102's
|
||||||
|
/// already-established convention: every installed heritage/town ships
|
||||||
|
/// unconditionally selectable, matching what a ToD-owning account would
|
||||||
|
/// see) — this reuses that SAME convention rather than inventing a
|
||||||
|
/// second one, so the heritage roll always uses the 4-heritage bound.
|
||||||
|
/// <c>SetHeritageGroupLocked</c> already rolls a starting area once as
|
||||||
|
/// part of its own <c>RandomizeStartAreaLocked</c> call (mirroring
|
||||||
|
/// retail's own <c>SetHeritageGroup</c>); the explicit
|
||||||
|
/// <c>RandomizeStartAreaLocked</c> call at the end re-rolls it a SECOND
|
||||||
|
/// time, matching retail's own redundant double-roll exactly (harmless —
|
||||||
|
/// each roll is independently uniform over the same list).
|
||||||
|
/// </summary>
|
||||||
|
private void RandomizeCharacterLocked()
|
||||||
|
{
|
||||||
|
ClearSessionState();
|
||||||
|
|
||||||
|
uint heritageId = (uint)RollDiceLocked(1, 4);
|
||||||
|
if (_options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
|
||||||
|
SetHeritageGroupLocked(heritageId, heritage);
|
||||||
|
|
||||||
|
uint genderKey = (uint)RollDiceLocked(1, 2);
|
||||||
|
SetGenderLocked(genderKey);
|
||||||
|
|
||||||
|
RandomizeAppearanceLocked();
|
||||||
|
RandomizeHeadgearLocked(excludeCurrent: false);
|
||||||
|
RandomizeShirtLocked();
|
||||||
|
RandomizeTrousersLocked();
|
||||||
|
RandomizeFootwearLocked();
|
||||||
|
RandomizeTemplateLocked();
|
||||||
|
if (_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? finalHeritage))
|
||||||
|
RandomizeStartAreaLocked(finalHeritage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public command surface for <see cref="RandomizeCharacterLocked"/> —
|
||||||
|
/// consumed by the App layer's screen-open edge (mirrors
|
||||||
|
/// <c>gmCharGenMainUI</c>'s ctor-time roll, retiring AP-214's
|
||||||
|
/// honest-blank deviation) and the Summary page's Random button
|
||||||
|
/// (<c>gmCharGenMainUI::DoRandom</c> case 5, behind the
|
||||||
|
/// <c>ID_CharGen_RandomizeWarning</c> confirmation the App layer
|
||||||
|
/// owns).</summary>
|
||||||
|
internal bool TryRandomizeCharacter()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed || !_active)
|
||||||
|
return false;
|
||||||
|
RandomizeCharacterLocked();
|
||||||
|
_revision++;
|
||||||
|
}
|
||||||
|
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public command surface for <see cref="RandomizeAppearanceLocked"/> —
|
||||||
|
/// the Appearance page's Random button when its Face sub-tab is showing
|
||||||
|
/// (<c>gmCharGenMainUI::DoRandom</c> case 3's <c>else</c> arm).</summary>
|
||||||
|
internal bool TryRandomizeAppearance()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed || !_active || _heritageId == 0 || _genderKey == 0)
|
||||||
|
return false;
|
||||||
|
RandomizeAppearanceLocked();
|
||||||
|
_revision++;
|
||||||
|
}
|
||||||
|
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public command surface for <see cref="RandomizeClothingLocked"/>
|
||||||
|
/// with <c>excludeCurrent: true</c> — the Appearance page's Random
|
||||||
|
/// button when its Clothes sub-tab is showing
|
||||||
|
/// (<c>gmCharGenMainUI::DoRandom</c> case 3's
|
||||||
|
/// <c>RandomizeClothing(state, 1)</c> arm).</summary>
|
||||||
|
internal bool TryRandomizeClothing()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed || !_active || _heritageId == 0 || _genderKey == 0)
|
||||||
|
return false;
|
||||||
|
RandomizeClothingLocked(excludeCurrent: true);
|
||||||
|
_revision++;
|
||||||
|
}
|
||||||
|
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Town / name / slot ─────────────────────────────────────────────
|
// ── Town / name / slot ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Ports <c>CharGenState::SetStartArea @ 0x005C4000</c> — bounds
|
/// <summary>Ports <c>CharGenState::SetStartArea @ 0x005C4000</c> — bounds
|
||||||
|
|
@ -1290,6 +1682,9 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
refusal = trimmed.Length == 0
|
refusal = trimmed.Length == 0
|
||||||
? new RuntimeCharacterCreationLocalRefusal(
|
? new RuntimeCharacterCreationLocalRefusal(
|
||||||
NoName: true, false, false, false)
|
NoName: true, false, false, false)
|
||||||
|
: _heritageId == 0 || _genderKey == 0
|
||||||
|
? new RuntimeCharacterCreationLocalRefusal(
|
||||||
|
false, false, false, false, HeritageOrGenderUnset: true)
|
||||||
: !confirmedUnspentCredits && _remainingAttributeCredits > 0
|
: !confirmedUnspentCredits && _remainingAttributeCredits > 0
|
||||||
? new RuntimeCharacterCreationLocalRefusal(
|
? new RuntimeCharacterCreationLocalRefusal(
|
||||||
false, AttributeCreditsUnspent: true, false, false)
|
false, AttributeCreditsUnspent: true, false, false)
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,8 @@ public sealed class CharacterCreationLiveDatTests
|
||||||
CharacterCreationUiController? controller =
|
CharacterCreationUiController? controller =
|
||||||
CharacterCreationUiController.CreateDetached(
|
CharacterCreationUiController.CreateDetached(
|
||||||
host, screen, ResolveTemplate, dialogs, bindings,
|
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);
|
Assert.NotNull(controller);
|
||||||
controller!.AttachAndTick();
|
controller!.AttachAndTick();
|
||||||
controller.Dispose();
|
controller.Dispose();
|
||||||
|
|
@ -515,6 +516,62 @@ public sealed class CharacterCreationLiveDatTests
|
||||||
return new RetailDialogFactory(host, CreateLayout);
|
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) =>
|
private static void AssertButton(ImportedLayout layout, uint elementId) =>
|
||||||
Assert.IsType<UiButton>(layout.FindElement(elementId));
|
Assert.IsType<UiButton>(layout.FindElement(elementId));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Numerics;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
using AcDream.Core.CharGen;
|
using AcDream.Core.CharGen;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
|
|
@ -93,18 +94,32 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
CharacterCreationUiController.SummaryPageElementId).Visible);
|
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]
|
[Fact]
|
||||||
public void Finish_StaysGhosted_NoOnClickHandler()
|
public void Finish_GhostedExceptOnSummary()
|
||||||
{
|
{
|
||||||
using var environment = new EnvironmentHarness();
|
using var environment = new EnvironmentHarness();
|
||||||
environment.Controller.Open();
|
environment.Controller.Open();
|
||||||
UiButton finish = environment.Button(CharacterCreationUiController.FinishElementId);
|
UiButton finish = environment.Button(CharacterCreationUiController.FinishElementId);
|
||||||
Assert.Null(finish.OnClick);
|
Assert.NotNull(finish.OnClick);
|
||||||
Assert.False(finish.Enabled);
|
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]
|
[Fact]
|
||||||
public void Random_IsDisabledOnSkillsAppearanceAndSummaryPages()
|
public void Random_IsDisabledOnSkillsPageOnly()
|
||||||
{
|
{
|
||||||
using var environment = new EnvironmentHarness();
|
using var environment = new EnvironmentHarness();
|
||||||
environment.Controller.Open();
|
environment.Controller.Open();
|
||||||
|
|
@ -115,10 +130,10 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
Assert.False(random.Enabled);
|
Assert.False(random.Enabled);
|
||||||
|
|
||||||
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
|
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
|
||||||
Assert.False(random.Enabled);
|
Assert.True(random.Enabled);
|
||||||
|
|
||||||
environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!();
|
environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!();
|
||||||
Assert.False(random.Enabled);
|
Assert.True(random.Enabled);
|
||||||
|
|
||||||
environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!();
|
environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!();
|
||||||
Assert.True(random.Enabled);
|
Assert.True(random.Enabled);
|
||||||
|
|
@ -791,6 +806,304 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!();
|
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
|
private sealed class FakeChargenPreviewControl : AcDream.App.Rendering.IChargenPreviewControl
|
||||||
{
|
{
|
||||||
public int ZoomInCalls { get; private set; }
|
public int ZoomInCalls { get; private set; }
|
||||||
|
|
@ -850,7 +1163,11 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
Dialogs,
|
Dialogs,
|
||||||
Runtime.Bindings,
|
Runtime.Bindings,
|
||||||
new CharacterCreationUiController.DialogStrings(
|
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();
|
Controller.AttachAndTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -874,6 +1191,9 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
public UiScrollbar ShadeScroll() =>
|
public UiScrollbar ShadeScroll() =>
|
||||||
Assert.IsType<UiScrollbar>(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId));
|
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
|
/// <summary>Confirms or cancels the MOST RECENTLY opened confirmation
|
||||||
/// dialog, using <see cref="RetailConfirmationDialogView"/>'s real
|
/// dialog, using <see cref="RetailConfirmationDialogView"/>'s real
|
||||||
/// button ids off the layout the factory's <c>createLayout</c>
|
/// button ids off the layout the factory's <c>createLayout</c>
|
||||||
|
|
@ -889,10 +1209,39 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
button.OnClick!();
|
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(
|
private static UiElement? ResolveSkillRowTemplate(
|
||||||
uint templateLayoutId,
|
uint templateLayoutId,
|
||||||
uint templateElementId) =>
|
uint templateElementId) =>
|
||||||
BuildSkillRowTemplate(templateElementId);
|
templateElementId switch
|
||||||
|
{
|
||||||
|
SummaryLineTemplateId => BuildSummaryLineTemplate(),
|
||||||
|
SummaryHeaderTemplateId => BuildSummaryHeaderTemplate(),
|
||||||
|
SummaryPairTemplateId => BuildSummaryPairTemplate(),
|
||||||
|
_ => BuildSkillRowTemplate(templateElementId),
|
||||||
|
};
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|
@ -919,11 +1268,16 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Specialized),
|
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Specialized),
|
||||||
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Untrained),
|
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Untrained),
|
||||||
SelectStartArea,
|
SelectStartArea,
|
||||||
_ => Result(RuntimeCommandStatus.Accepted),
|
Finish,
|
||||||
() => RequestExitCalls++,
|
() => RequestExitCalls++,
|
||||||
SetAppearanceIndex: SetAppearanceIndex,
|
SetAppearanceIndex: SetAppearanceIndex,
|
||||||
SetShade: SetShade,
|
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);
|
OpenOnStart: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -942,6 +1296,36 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
public int AppearanceIndexCallCount { get; private set; }
|
public int AppearanceIndexCallCount { get; private set; }
|
||||||
public ChargenShadeSlot? LastShadeSlot { get; private set; }
|
public ChargenShadeSlot? LastShadeSlot { get; private set; }
|
||||||
public double LastShadeValue { 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);
|
public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId);
|
||||||
|
|
||||||
|
|
@ -1007,6 +1391,65 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
return Result(RuntimeCommandStatus.Accepted);
|
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(
|
private static RuntimeCharacterCreationAppearance WithAppearanceIndex(
|
||||||
RuntimeCharacterCreationAppearance a,
|
RuntimeCharacterCreationAppearance a,
|
||||||
ChargenAppearanceSlot slot,
|
ChargenAppearanceSlot slot,
|
||||||
|
|
@ -1227,7 +1670,7 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
root.Children.Add(BuildSkillsPage());
|
root.Children.Add(BuildSkillsPage());
|
||||||
root.Children.Add(BuildAppearancePage());
|
root.Children.Add(BuildAppearancePage());
|
||||||
root.Children.Add(BuildTownPage());
|
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.HeritageTabElementId));
|
||||||
root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId));
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId));
|
||||||
|
|
@ -1419,6 +1862,77 @@ public sealed class CharacterCreationUiControllerTests
|
||||||
_ => (0u, 0, 0),
|
_ => (0u, 0, 0),
|
||||||
null).Root;
|
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()
|
private static ElementInfo ContainerInfo(uint id) => new()
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,8 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
||||||
_dialogs,
|
_dialogs,
|
||||||
Runtime.Bindings,
|
Runtime.Bindings,
|
||||||
new CharacterCreationUiController.DialogStrings(
|
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();
|
Controller.AttachAndTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@ internal static class RuntimeCharacterCreationStateFixture
|
||||||
public const uint ImpoverishedId = 90u;
|
public const uint ImpoverishedId = 90u;
|
||||||
public const uint MaleGenderKey = 1u;
|
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
|
/// <summary>str=10 end=10 coord=10 quick=10 focus=10 self=10 — the
|
||||||
/// budget-66 heritage leaves 6 credits unspent after this template,
|
/// budget-66 heritage leaves 6 credits unspent after this template,
|
||||||
/// matching retail's "Custom sits at the floor" finding.</summary>
|
/// matching retail's "Custom sits at the floor" finding.</summary>
|
||||||
|
|
@ -99,6 +108,16 @@ internal static class RuntimeCharacterCreationStateFixture
|
||||||
Footwear: [new ChargenGearOption("Boots", 4u, 303u)],
|
Footwear: [new ChargenGearOption("Boots", 4u, 303u)],
|
||||||
ClothingColors: [400u, 401u, 402u]);
|
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>
|
var aluvianTemplates = new List<ChargenTemplate>
|
||||||
{
|
{
|
||||||
new(
|
new(
|
||||||
|
|
@ -129,7 +148,7 @@ internal static class RuntimeCharacterCreationStateFixture
|
||||||
SecondaryStartAreaIndices: [],
|
SecondaryStartAreaIndices: [],
|
||||||
SkillCostsBySkillId: skillCosts,
|
SkillCostsBySkillId: skillCosts,
|
||||||
Templates: aluvianTemplates,
|
Templates: aluvianTemplates,
|
||||||
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)MaleGenderKey] = gender });
|
GendersByKey: bothGenders);
|
||||||
|
|
||||||
var olthoiTemplates = new List<ChargenTemplate>
|
var olthoiTemplates = new List<ChargenTemplate>
|
||||||
{
|
{
|
||||||
|
|
@ -163,6 +182,28 @@ internal static class RuntimeCharacterCreationStateFixture
|
||||||
Templates: olthoiTemplates,
|
Templates: olthoiTemplates,
|
||||||
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)MaleGenderKey] = gender });
|
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
|
// A deliberately impoverished heritage — just enough skill credits
|
||||||
// to train SkillTrainSpecialize but never specialize it — so a
|
// to train SkillTrainSpecialize but never specialize it — so a
|
||||||
// TrySpecializeSkill affordability refusal is directly testable
|
// TrySpecializeSkill affordability refusal is directly testable
|
||||||
|
|
@ -198,6 +239,12 @@ internal static class RuntimeCharacterCreationStateFixture
|
||||||
new Dictionary<uint, ChargenHeritageOptions>
|
new Dictionary<uint, ChargenHeritageOptions>
|
||||||
{
|
{
|
||||||
[AluvianId] = aluvian,
|
[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,
|
[OlthoiId] = olthoi,
|
||||||
[ImpoverishedId] = impoverished,
|
[ImpoverishedId] = impoverished,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -555,6 +555,42 @@ public sealed class RuntimeCharacterCreationStateTests
|
||||||
Assert.True(refusal.AlreadyPending);
|
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]
|
[Fact]
|
||||||
public void TryBeginFinish_RosterAtSlotCap_IsRefused()
|
public void TryBeginFinish_RosterAtSlotCap_IsRefused()
|
||||||
{
|
{
|
||||||
|
|
@ -700,4 +736,162 @@ public sealed class RuntimeCharacterCreationStateTests
|
||||||
|
|
||||||
Assert.Null(state.Snapshot.LastRejection);
|
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