feat(app): Campaign CC slice CC4 — chargen screen shell + Heritage/Profession/Skills/Town pages

Mounts gmCharGenMainUI (enum 0x10000039, root 0x100003CC) via
CharacterCreationUiController/CharacterCreationUiMountCoordinator,
cloning CharacterManagementUiController's recipe. Master shell ports
SetProgressState @0x004e7a10 (Olthoi tab-hide + redirect) and
ListenToElementMessage @0x004e9450 (Back/Next/Finish/Help/Exit/Random
nav) verbatim, with free tab navigation over all six pages. Heritage,
Profession, Skills, and Town pages bind to CC3's
RuntimeCharacterCreationState commands; Appearance and Summary mount as
content-inert placeholders for CC6b/CC5.

Live-DAT probing (CharacterCreationLiveDatTests) found two widget-
mapping surprises the decomp's DynamicCast hints don't predict: the
Profession slider's value field imports as an editable UiField (wired
for direct numeric entry), and the avail/health/stamina/mana/credits
displays author as UIElement_Button hosts whose Type-12 value child is
swallowed by UiButton.ConsumesDatChildren — substituted with the
button's own Label. No new DatWidgetFactory widget types were needed.

Threads the installed DAT's real ChargenOptions into Runtime via the
new RuntimeCharacterCreationState.InstallOptions, called from
ContentEffectsAudioCompositionPhase.Compose (mirrors
InstallSpellMetadata's pattern); headless keeps ChargenOptions.Empty
unchanged. Wires CC3's F14 status-hook gap (ApplyCharacterCreated/
ApplyCreationFailed) to SessionStatusWriter for both graphical and
headless hosts, and adds the CharacterCreation view/command seam
through CurrentGameRuntimeAdapter and DeferredGameRuntimeStateCommands
alongside CharacterSelection's existing shape.

Register: AD-101/102/103, AP-212/213, TS-82 filed for the auto-gender-
select interim default, the omitted ToD-account gate, the button-Label
widget substitution, the Random-button approximation, the flat-listbox
Skills simplification, and the Appearance/Summary placeholders.

Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6), Headless
165/0 unaffected, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 17:45:51 +02:00
parent 3a6b7e3115
commit 0e71d3b829
27 changed files with 3344 additions and 12 deletions

View file

@ -57,6 +57,54 @@ public sealed class RuntimeCharacterCreationStateTests
Assert.False(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId));
}
// ── Options threading (Campaign CC slice CC4) ───────────────────────
// RuntimeCharacterCreationState.InstallOptions — the App-startup seam
// ContentEffectsAudioCompositionPhase.Compose calls once portal.dat's
// ChargenTableReader.Load result is available, mirroring
// RuntimeCharacterState.InstallSpellMetadata's "install immutable DAT
// metadata after construction" pattern.
[Fact]
public void InstallOptions_BeforeBegin_ReplacesTheOptionsLaterCommandsUse()
{
var state = new RuntimeCharacterCreationState(ChargenOptions.Empty);
state.InstallOptions(RuntimeCharacterCreationStateFixture.Build());
state.Begin(new RuntimeGenerationToken(1));
Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId));
Assert.Equal(
RuntimeCharacterCreationStateFixture.AluvianId,
state.Snapshot.HeritageId);
}
[Fact]
public void InstallOptions_WhileActive_ThrowsInsteadOfRacingLiveCommands()
{
RuntimeCharacterCreationState state = CreateActive();
Assert.Throws<InvalidOperationException>(
() => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build()));
}
[Fact]
public void InstallOptions_NullOptions_Throws()
{
var state = new RuntimeCharacterCreationState(ChargenOptions.Empty);
Assert.Throws<ArgumentNullException>(() => state.InstallOptions(null!));
}
[Fact]
public void InstallOptions_AfterDispose_Throws()
{
var state = new RuntimeCharacterCreationState(ChargenOptions.Empty);
state.Dispose();
Assert.Throws<ObjectDisposedException>(
() => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build()));
}
// ── Heritage / gender / template ────────────────────────────────────
[Fact]

View file

@ -40,6 +40,61 @@ public sealed class LiveSessionLifecycleHostTests
host.DetachSession(sessionB);
}
/// <summary>Campaign CC slice CC4: <see cref="LiveSessionLifecycleHost.ApplyCharacterCreated"/>/
/// <see cref="LiveSessionLifecycleHost.ApplyCreationFailed"/> forward to
/// the bindings' new optional delegates.</summary>
[Fact]
public void CharacterCreatedAndCreationFailed_ForwardToTheOptionalBindings()
{
var calls = new List<string>();
var host = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings(
Bind: session => CreateBinding(session, calls),
Reset: _ => { },
Connecting: (_, _, _) => { },
Connected: () => { },
Roster: _ => { },
Selected: _ => { },
Entered: _ => { },
CharacterCreated: identity => calls.Add($"created:{identity.Guid:X8}:{identity.Name}"),
CreationFailed: rejection => calls.Add($"failed:{rejection.Reason}")));
host.ApplyCharacterCreated(new RuntimeCharacterCreationIdentity(0x50000001u, "Toon"));
host.ApplyCreationFailed(new RuntimeCharacterCreationRejection(
3u,
AcDream.Core.Net.Messages.CharGenVerificationResponse.Code.NameInUse,
"NameInUse",
"Toon"));
Assert.Equal(["created:50000001:Toon", "failed:NameInUse"], calls);
}
/// <summary>The two delegates default to <see langword="null"/> — every
/// construction site that predates CC4 keeps compiling and behaves as a
/// no-op, matching <see cref="ILiveSessionLifecycleHost.ApplyCharacterCreated"/>'s
/// own default-interface no-op.</summary>
[Fact]
public void CharacterCreatedAndCreationFailed_DefaultToNoOp_WhenBindingsOmitThem()
{
var calls = new List<string>();
var host = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings(
Bind: session => CreateBinding(session, calls),
Reset: _ => { },
Connecting: (_, _, _) => { },
Connected: () => { },
Roster: _ => { },
Selected: _ => { },
Entered: _ => { }));
host.ApplyCharacterCreated(new RuntimeCharacterCreationIdentity(1u, "Toon"));
host.ApplyCreationFailed(new RuntimeCharacterCreationRejection(
3u,
AcDream.Core.Net.Messages.CharGenVerificationResponse.Code.NameInUse,
"NameInUse",
"Toon"));
Assert.Empty(calls);
}
[Fact]
public void FailedBindingFactoryDoesNotClaimTheHost()
{