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>
173 lines
6.4 KiB
C#
173 lines
6.4 KiB
C#
using System.Net;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.Runtime.Tests.Session;
|
|
|
|
public sealed class LiveSessionLifecycleHostTests
|
|
{
|
|
[Fact]
|
|
public void HostRoutesLifecycleAndReleasesOnlyTheExactBoundSession()
|
|
{
|
|
var calls = new List<string>();
|
|
using var sessionA = CreateSession(9000);
|
|
using var sessionB = CreateSession(9001);
|
|
var host = CreateHost(calls);
|
|
|
|
LiveSessionBinding binding = host.BindSession(sessionA);
|
|
host.ResetSessionState(RuntimeGenerationToken.Initial);
|
|
host.ReportConnecting("host", 9000, "user");
|
|
host.ReportConnected();
|
|
host.ReportRoster(new LiveSessionRosterReport("account", 11, []));
|
|
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
|
|
host.ApplySelectedCharacter(selection);
|
|
binding.ActivateCommands();
|
|
host.ApplyEnteredWorld(selection);
|
|
|
|
Assert.Throws<InvalidOperationException>(() => host.DetachSession(sessionB));
|
|
binding.Dispose();
|
|
host.DetachSession(sessionA);
|
|
LiveSessionBinding replacement = host.BindSession(sessionB);
|
|
|
|
Assert.Equal(
|
|
[
|
|
"bind", "reset", "connecting:host:9000:user",
|
|
"connected", "roster:account", "selected:toon", "activate",
|
|
"entered:toon", "deactivate", "detach-events", "bind",
|
|
],
|
|
calls);
|
|
replacement.Dispose();
|
|
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()
|
|
{
|
|
var calls = new List<string>();
|
|
using var session = CreateSession(9000);
|
|
bool fail = true;
|
|
LiveSessionLifecycleHost host = CreateHost(calls, _ =>
|
|
{
|
|
if (fail)
|
|
{
|
|
fail = false;
|
|
throw new InvalidOperationException("bind failure");
|
|
}
|
|
return CreateBinding(session, calls);
|
|
});
|
|
|
|
Assert.Throws<InvalidOperationException>(() => host.BindSession(session));
|
|
LiveSessionBinding retry = host.BindSession(session);
|
|
|
|
retry.Dispose();
|
|
host.DetachSession(session);
|
|
}
|
|
|
|
private static LiveSessionLifecycleHost CreateHost(
|
|
List<string> calls,
|
|
Func<WorldSession, LiveSessionBinding>? bind = null) =>
|
|
new(new LiveSessionLifecycleBindings(
|
|
Bind: bind ?? (session => CreateBinding(session, calls)),
|
|
Reset: _ => calls.Add("reset"),
|
|
Connecting: (host, port, user) =>
|
|
calls.Add($"connecting:{host}:{port}:{user}"),
|
|
Connected: () => calls.Add("connected"),
|
|
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
|
|
Selected: selection => calls.Add($"selected:{selection.CharacterName}"),
|
|
Entered: selection => calls.Add($"entered:{selection.CharacterName}")));
|
|
|
|
private static LiveSessionBinding CreateBinding(
|
|
WorldSession session,
|
|
List<string> calls)
|
|
{
|
|
calls.Add("bind");
|
|
return new LiveSessionBinding(
|
|
session,
|
|
activateCommands: () => calls.Add("activate"),
|
|
deactivateCommands: () => calls.Add("deactivate"),
|
|
detachEvents: () => calls.Add("detach-events"));
|
|
}
|
|
|
|
private static WorldSession CreateSession(int port) =>
|
|
new(
|
|
new IPEndPoint(IPAddress.Loopback, port),
|
|
new TestTransport());
|
|
|
|
private sealed class TestTransport : IWorldSessionTransport
|
|
{
|
|
public void Send(ReadOnlySpan<byte> datagram) { }
|
|
|
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
|
|
|
|
public int Receive(
|
|
Span<byte> destination,
|
|
TimeSpan timeout,
|
|
out IPEndPoint? from)
|
|
{
|
|
from = null;
|
|
return -1;
|
|
}
|
|
|
|
public ValueTask<NetReceiveResult> ReceiveAsync(
|
|
Memory<byte> destination,
|
|
CancellationToken cancellationToken) =>
|
|
throw new OperationCanceledException(cancellationToken);
|
|
|
|
public void Dispose() { }
|
|
}
|
|
}
|