Two live-integration gaps the ef96c554 unit tests could not see:
1. World box stayed empty against ACE: ServerName (0xF7E1) arrives in the
SAME connect batch as CharacterList, so ServerNameReceived fires during
the handshake pump BEFORE the controller binding subscribes - the
event-only wiring proved the state and controller but never the live
ordering. StartCore now reads the durable WorldSession.ServerInfo after
connect exactly like the roster (ILiveSessionOperations.GetServerInfo,
default interface method so no fake breaks); the event remains for
post-connect updates. Pinned by a Start-level test.
2. The exit confirmation rendered far right of the screen: all three
retail dialog views centered against the raw window size while the
active screen lays out in the fixed 800x600 canvas - center-of-1920
is canvas-760, which the stretch pushes off-center. Views now center
against UiRoot.EffectiveCanvasSize (canvas while a pre-world screen is
active, window otherwise). Pinned by growing the window over the fixed
canvas in the exit-dialog test and asserting the scrim spans the canvas
with the popup centered at 400.
Runtime 1666, App 5100+6 skips, green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1164 lines
44 KiB
C#
1164 lines
44 KiB
C#
using System.Numerics;
|
||
using AcDream.App.UI;
|
||
using AcDream.App.UI.Layout;
|
||
using AcDream.Runtime;
|
||
using AcDream.Runtime.Session;
|
||
|
||
namespace AcDream.App.Tests.UI.Layout;
|
||
|
||
public sealed class CharacterManagementUiControllerTests
|
||
{
|
||
/// <summary>
|
||
/// Campaign LA gate round 2 (register AD-98): the root KEEPS its authored
|
||
/// 800×600 extent (retail never resizes it — zero edge anchors), and while
|
||
/// the screen is active the HOST carries the fixed canvas so the whole tree
|
||
/// — widgets, glyphs, and the painted background whose art contains the
|
||
/// World/Characters captions — stretches together. Resizing the root while
|
||
/// stretching only the art is exactly the misalignment the 2026-08-15 user
|
||
/// gate caught. Dispose must release the canvas so in-world UI returns to
|
||
/// native pixels.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ActiveScreen_KeepsAuthoredRootExtent_AndSetsHostFixedCanvas()
|
||
{
|
||
var environment = new EnvironmentHarness();
|
||
try
|
||
{
|
||
UiElement root = environment.Controller.Root;
|
||
Assert.Equal(800f, root.Width);
|
||
Assert.Equal(600f, root.Height);
|
||
Assert.Equal(
|
||
new Vector2(root.Width, root.Height),
|
||
environment.Host.FixedCanvasSize);
|
||
}
|
||
finally
|
||
{
|
||
environment.Dispose();
|
||
}
|
||
|
||
Assert.Null(environment.Host.FixedCanvasSize);
|
||
}
|
||
|
||
[Fact]
|
||
public void AuthoredChildContract_PreservesRuntimeOrderGreyTailHighlightAndButtonMatrix()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
|
||
Assert.True(controller.Root.Visible);
|
||
Assert.Equal(
|
||
CharacterManagementUiController.RootElementId,
|
||
controller.Root.DatElementId);
|
||
Assert.IsType<UiTemplateListBox>(environment.Screen.FindElement(
|
||
CharacterManagementUiController.ListElementId));
|
||
UiButton create = environment.Button(
|
||
CharacterManagementUiController.CreateElementId);
|
||
UiButton enter = environment.Button(
|
||
CharacterManagementUiController.EnterElementId);
|
||
UiButton delete = environment.Button(
|
||
CharacterManagementUiController.DeleteElementId);
|
||
UiButton restore = environment.Button(
|
||
CharacterManagementUiController.RestoreElementId);
|
||
|
||
Assert.True(create.Visible);
|
||
Assert.False(create.Enabled);
|
||
Assert.Null(create.OnClick);
|
||
Assert.True(enter.Enabled);
|
||
Assert.True(delete.Visible);
|
||
Assert.True(delete.Enabled);
|
||
Assert.False(restore.Visible);
|
||
Assert.False(restore.Enabled);
|
||
|
||
// Runtime owns wcscmp sorting and the stable grey-to-tail partition.
|
||
// "Aaron (pending)" would sort first if App incorrectly re-sorted it;
|
||
// the controller must preserve this exact borrowed display order.
|
||
Assert.Equal(
|
||
["Alpha", "Zulu", "Aaron (pending)"],
|
||
controller.Rows.Select(static row => row.Label!).ToArray());
|
||
Assert.All(controller.Rows, static row => Assert.Equal(64f, row.Height));
|
||
Assert.Equal([0f, 64f, 128f],
|
||
controller.Rows.Select(static row => row.Top).ToArray());
|
||
Assert.True(controller.Rows[0].Selected);
|
||
Assert.False(controller.Rows[1].Selected);
|
||
Assert.Equal(Vector4.One, controller.Rows[0].LabelColor);
|
||
Assert.Equal(new Vector4(1f, 0f, 0f, 1f), controller.Rows[2].LabelColor);
|
||
Assert.DoesNotContain(
|
||
Descendants(controller.Root),
|
||
static element => element is UiViewport);
|
||
|
||
controller.Rows[2].OnClick!();
|
||
|
||
Assert.Equal(1, environment.Runtime.HighlightCalls);
|
||
Assert.Equal(0x50000003u,
|
||
environment.Runtime.View.Snapshot.HighlightedCharacterId);
|
||
Assert.True(controller.Rows[2].Selected);
|
||
Assert.False(enter.Enabled);
|
||
Assert.False(delete.Visible);
|
||
Assert.False(delete.Enabled);
|
||
Assert.True(restore.Visible);
|
||
Assert.True(restore.Enabled);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120
|
||
/// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName()
|
||
/// onto element 0x1000039B. The controller binds Runtime's borrowed
|
||
/// snapshot field to that same element.
|
||
/// </summary>
|
||
[Fact]
|
||
public void WorldName_TicksFromSnapshot_IntoTheWorldTextElement()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
var worldText = Assert.IsType<UiText>(environment.Screen.FindElement(
|
||
CharacterManagementUiController.WorldTextElementId));
|
||
|
||
Assert.Equal(
|
||
"sawato",
|
||
string.Join(" ", worldText.LinesProvider().Select(static line => line.Text)));
|
||
|
||
environment.Runtime.SetWorldName("Frostfell");
|
||
controller.Tick();
|
||
|
||
Assert.Equal(
|
||
"Frostfell",
|
||
string.Join(" ", worldText.LinesProvider().Select(static line => line.Text)));
|
||
}
|
||
|
||
[Fact]
|
||
public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
|
||
Assert.Equal(64, CharacterManagementUiController.ComputeRowHeight(
|
||
320f,
|
||
rosterCount: 3,
|
||
allowedSlotCount: 5));
|
||
Assert.Equal(63, CharacterManagementUiController.ComputeRowHeight(
|
||
319f,
|
||
rosterCount: 5,
|
||
allowedSlotCount: 5));
|
||
Assert.Equal(31, CharacterManagementUiController.ComputeRowHeight(
|
||
319f,
|
||
rosterCount: 11,
|
||
allowedSlotCount: 5));
|
||
|
||
RuntimeCharacterSelectionEntry[] large = Enumerable.Range(0, 12)
|
||
.Select(index => new RuntimeCharacterSelectionEntry(
|
||
index,
|
||
(uint)(0x50000100 + index),
|
||
$"Character {index:D2}",
|
||
0u))
|
||
.ToArray();
|
||
environment.Runtime.ReplaceRoster(
|
||
large,
|
||
highlightedCharacterId: large[0].CharacterId);
|
||
controller.Tick();
|
||
|
||
Assert.Equal(12, controller.Rows.Count);
|
||
Assert.All(controller.Rows, static row => Assert.Equal(32f, row.Height));
|
||
Assert.Equal(
|
||
Enumerable.Range(0, 12).Select(static index => index * 32f),
|
||
controller.Rows.Select(static row => row.Top));
|
||
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
|
||
environment.Screen.FindElement(
|
||
CharacterManagementUiController.ListElementId));
|
||
Assert.Equal(384, list.ContentHeight);
|
||
Assert.Equal(32, list.LineHeight);
|
||
}
|
||
|
||
[Fact]
|
||
public void MountCoordinator_RetriesCatalogRootAndStrings_ThenMountsOnce()
|
||
{
|
||
var host = new UiRoot();
|
||
var runtime = new FakeRuntime();
|
||
using var dialogs = new RetailDialogFactory(
|
||
host,
|
||
RetailDialogFactoryTests.BuildDialogLayout);
|
||
bool catalogAvailable = false;
|
||
bool rootAvailable = false;
|
||
bool stringsAvailable = false;
|
||
int dialogAttempts = 0;
|
||
int resourceAttempts = 0;
|
||
using var coordinator = new CharacterManagementUiMountCoordinator(
|
||
host,
|
||
runtime.Bindings,
|
||
() =>
|
||
{
|
||
dialogAttempts++;
|
||
return catalogAvailable ? dialogs : null;
|
||
},
|
||
() =>
|
||
{
|
||
resourceAttempts++;
|
||
if (!rootAvailable || !stringsAvailable)
|
||
return null;
|
||
return new CharacterManagementUiMountResources(
|
||
0x21000004u,
|
||
BuildScreen(),
|
||
static (layoutId, elementId) =>
|
||
layoutId == 0x21000004u
|
||
&& elementId == 0x100003A5u
|
||
? BuildRow()
|
||
: null,
|
||
TestStrings());
|
||
});
|
||
|
||
coordinator.Tick();
|
||
Assert.Null(coordinator.Controller);
|
||
Assert.Equal(1, dialogAttempts);
|
||
Assert.Equal(0, resourceAttempts);
|
||
|
||
catalogAvailable = true;
|
||
coordinator.Tick();
|
||
Assert.Null(coordinator.Controller);
|
||
Assert.Equal(2, dialogAttempts);
|
||
Assert.Equal(1, resourceAttempts);
|
||
|
||
rootAvailable = true;
|
||
coordinator.Tick();
|
||
Assert.Null(coordinator.Controller);
|
||
Assert.Equal(3, dialogAttempts);
|
||
Assert.Equal(2, resourceAttempts);
|
||
|
||
stringsAvailable = true;
|
||
coordinator.Tick();
|
||
CharacterManagementUiController controller = Assert.IsType<
|
||
CharacterManagementUiController>(coordinator.Controller);
|
||
Assert.Single(host.Children);
|
||
Assert.Same(controller.Root, host.Children[0]);
|
||
Assert.Equal(4, dialogAttempts);
|
||
Assert.Equal(3, resourceAttempts);
|
||
|
||
coordinator.Tick();
|
||
Assert.Same(controller, coordinator.Controller);
|
||
Assert.Single(host.Children);
|
||
Assert.Equal(4, dialogAttempts);
|
||
Assert.Equal(3, resourceAttempts);
|
||
|
||
coordinator.Dispose();
|
||
Assert.Empty(host.Children);
|
||
coordinator.Tick();
|
||
Assert.Null(coordinator.Controller);
|
||
Assert.Equal(4, dialogAttempts);
|
||
Assert.Equal(3, resourceAttempts);
|
||
}
|
||
|
||
[Fact]
|
||
public void MountCoordinator_PostAttachFailuresDisposeBeforeRetryAndRecovery()
|
||
{
|
||
var host = new UiRoot { Width = 800f, Height = 600f };
|
||
var runtime = new FakeRuntime();
|
||
using var dialogs = new RetailDialogFactory(
|
||
host,
|
||
RetailDialogFactoryTests.BuildDialogLayout);
|
||
var screens = new List<ImportedLayout>();
|
||
int failingAttempts = 2;
|
||
using var coordinator = new CharacterManagementUiMountCoordinator(
|
||
host,
|
||
runtime.Bindings,
|
||
() => dialogs,
|
||
() =>
|
||
{
|
||
ImportedLayout screen = BuildScreen();
|
||
screens.Add(screen);
|
||
bool throwAfterAttach = failingAttempts-- > 0;
|
||
return new CharacterManagementUiMountResources(
|
||
0x21000004u,
|
||
screen,
|
||
(_, _) => throwAfterAttach
|
||
? throw new InvalidOperationException(
|
||
"template failed after root attach")
|
||
: BuildRow(),
|
||
TestStrings());
|
||
});
|
||
|
||
coordinator.Tick();
|
||
Assert.Null(coordinator.Controller);
|
||
Assert.Empty(host.Children);
|
||
Assert.Single(screens);
|
||
AssertDetachedAndUnbound(screens[0]);
|
||
|
||
coordinator.Tick();
|
||
Assert.Null(coordinator.Controller);
|
||
Assert.Empty(host.Children);
|
||
Assert.Equal(2, screens.Count);
|
||
Assert.All(screens, AssertDetachedAndUnbound);
|
||
|
||
coordinator.Tick();
|
||
CharacterManagementUiController mounted = Assert.IsType<
|
||
CharacterManagementUiController>(coordinator.Controller);
|
||
Assert.Equal(3, screens.Count);
|
||
Assert.Single(host.Children);
|
||
Assert.Same(mounted.Root, host.Children[0]);
|
||
Assert.NotNull(Assert.IsType<UiButton>(screens[2].FindElement(
|
||
CharacterManagementUiController.EnterElementId)).OnClick);
|
||
|
||
coordinator.Tick();
|
||
Assert.Equal(3, screens.Count);
|
||
Assert.Single(host.Children);
|
||
|
||
coordinator.Dispose();
|
||
Assert.Empty(host.Children);
|
||
Assert.Null(coordinator.Controller);
|
||
Assert.All(screens, AssertDetachedAndUnbound);
|
||
}
|
||
|
||
[Fact]
|
||
public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
UiButton delete = environment.Button(
|
||
CharacterManagementUiController.DeleteElementId);
|
||
|
||
// A wrong typed response closes the modal and cancels Runtime's exact
|
||
// pending-delete owner without sending the wire request.
|
||
delete.OnClick!();
|
||
ImportedLayout wrong = environment.LastDialog(
|
||
RetailDialogType.ConfirmationTextInput);
|
||
Assert.Contains("Alpha", Message(wrong));
|
||
Assert.Contains("Type DELETE", Message(wrong));
|
||
Input(wrong).SetText("not delete");
|
||
DialogButton(
|
||
wrong,
|
||
RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!();
|
||
Assert.Equal(1, environment.Runtime.CancelCalls);
|
||
Assert.Equal(0, environment.Runtime.ConfirmDeleteCalls);
|
||
Assert.Equal(0u, controller.DeleteDialogContext);
|
||
Assert.False(environment.Dialogs.IsOpen);
|
||
|
||
// Retail compares the localized response case-insensitively.
|
||
delete.OnClick!();
|
||
ImportedLayout accepted = environment.LastDialog(
|
||
RetailDialogType.ConfirmationTextInput);
|
||
Input(accepted).SetText("delete");
|
||
DialogButton(
|
||
accepted,
|
||
RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!();
|
||
|
||
Assert.Equal(1, environment.Runtime.ConfirmDeleteCalls);
|
||
Assert.Equal(
|
||
RuntimeCharacterSelectionOperation.DeleteRequested,
|
||
environment.Runtime.View.Snapshot.Operation);
|
||
uint waitContext = controller.OperationWaitContext;
|
||
Assert.NotEqual(0u, waitContext);
|
||
Assert.Equal(
|
||
RetailDialogType.Wait,
|
||
environment.DialogLayouts[^1].Type);
|
||
Assert.Equal("Please Wait", Message(environment.DialogLayouts[^1].Layout));
|
||
|
||
// Opcode-only ack does not close the wait. Neither does silence.
|
||
environment.Runtime.SetOperation(
|
||
RuntimeCharacterSelectionOperation.DeleteAcknowledged);
|
||
controller.Tick();
|
||
controller.Tick();
|
||
Assert.Equal(waitContext, controller.OperationWaitContext);
|
||
|
||
// Retail closes via the fresh CharacterList rebuild that follows ack.
|
||
environment.Runtime.ReplaceRoster(
|
||
[
|
||
new RuntimeCharacterSelectionEntry(1, 0x50000002u, "Zulu", 0u),
|
||
new RuntimeCharacterSelectionEntry(2, 0x50000003u, "Aaron (pending)", 1u),
|
||
],
|
||
highlightedCharacterId: 0x50000002u);
|
||
controller.Tick();
|
||
|
||
Assert.Equal(0u, controller.OperationWaitContext);
|
||
Assert.False(environment.Dialogs.IsOpen);
|
||
Assert.Equal(
|
||
["Zulu", "Aaron (pending)"],
|
||
controller.Rows.Select(static row => row.Label!).ToArray());
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign LA gate round 2 finding 1: Exit -> MakeConfirmExitDialog
|
||
/// (0x004ed250, retail's confirm-only dialog type 1) -> Cancel/Reject
|
||
/// leaves the screen exactly as it was — no exit request reaches
|
||
/// Runtime's window-close binding.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ExitButton_OpenThenCancel_KeepsScreenActive_NoExitRequested()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
UiButton exit = environment.Button(
|
||
CharacterManagementUiController.ExitElementId);
|
||
|
||
// Gate round 2 follow-up: the window is BIGGER than the fixed canvas
|
||
// (the live shape — 1920×1080 window over the 800×600 authored
|
||
// screen). The exit dialog centered against the raw window width and
|
||
// landed far right of the visible canvas center.
|
||
environment.Host.Width = 1920f;
|
||
environment.Host.Height = 1080f;
|
||
|
||
exit.OnClick!();
|
||
|
||
Assert.NotEqual(0u, controller.ConfirmExitDialogContext);
|
||
ImportedLayout dialog = environment.LastDialog(RetailDialogType.Confirmation);
|
||
Assert.Equal(
|
||
"Are you sure you want to leave?",
|
||
Message(dialog));
|
||
// The dialog's scrim + popup must live in CANVAS space: scrim spans
|
||
// exactly the canvas, popup centers within it.
|
||
Assert.Equal(800f, dialog.Root.Width);
|
||
Assert.Equal(600f, dialog.Root.Height);
|
||
UiElement popup = Assert.Single(
|
||
dialog.Root.Children,
|
||
static child => child.Visible && child.Width > 0f);
|
||
Assert.InRange(popup.Left + popup.Width * 0.5f, 399f, 401f);
|
||
DialogButton(dialog, RetailConfirmationDialogView.RejectButtonId).OnClick!();
|
||
|
||
Assert.Equal(0, environment.Runtime.RequestExitCalls);
|
||
Assert.Equal(0u, controller.ConfirmExitDialogContext);
|
||
Assert.False(environment.Dialogs.IsOpen);
|
||
Assert.True(controller.Root.Visible);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Confirm reaches the SAME graceful-shutdown seam window-close uses —
|
||
/// asserted here via the bindings fake, since the controller/Runtime
|
||
/// boundary is a plain host <c>Action</c>
|
||
/// (<see cref="CharacterSelectionRuntimeBindings.RequestExit"/>), not a
|
||
/// generation-gated Runtime command.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ExitButton_OpenThenConfirm_ReachesGracefulShutdownSeam()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
UiButton exit = environment.Button(
|
||
CharacterManagementUiController.ExitElementId);
|
||
|
||
exit.OnClick!();
|
||
ImportedLayout dialog = environment.LastDialog(RetailDialogType.Confirmation);
|
||
DialogButton(dialog, RetailConfirmationDialogView.AcceptButtonId).OnClick!();
|
||
|
||
Assert.Equal(1, environment.Runtime.RequestExitCalls);
|
||
Assert.Equal(0u, controller.ConfirmExitDialogContext);
|
||
Assert.False(environment.Dialogs.IsOpen);
|
||
}
|
||
|
||
/// <summary>
|
||
/// MakeConfirmExitDialog's own guard (<c>m_confirmExitDialogContext != 0
|
||
/// -> return</c>): a second Exit click while the confirmation is
|
||
/// already open does not open a second dialog.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ExitButton_SecondClickWhileOpen_IsNoOp()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
UiButton exit = environment.Button(
|
||
CharacterManagementUiController.ExitElementId);
|
||
|
||
exit.OnClick!();
|
||
Assert.Equal(1, environment.DialogLayouts.Count(
|
||
entry => entry.Type == RetailDialogType.Confirmation));
|
||
|
||
exit.OnClick!();
|
||
|
||
Assert.Equal(1, environment.DialogLayouts.Count(
|
||
entry => entry.Type == RetailDialogType.Confirmation));
|
||
}
|
||
|
||
[Fact]
|
||
public void AuthoredRowDoubleActivation_EntersTheHighlightedCharacter()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
UiButton row = controller.Rows[1];
|
||
|
||
Assert.True(row.OnEvent(new UiEvent(
|
||
row.EventId,
|
||
row,
|
||
UiEventType.Click)));
|
||
Assert.True(row.OnEvent(new UiEvent(
|
||
row.EventId,
|
||
row,
|
||
UiEventType.DoubleClick)));
|
||
|
||
Assert.Equal(1, environment.Runtime.HighlightCalls);
|
||
Assert.Equal(0x50000002u,
|
||
environment.Runtime.View.Snapshot.HighlightedCharacterId);
|
||
Assert.Equal(1, environment.Runtime.EnterCalls);
|
||
Assert.Equal(
|
||
RuntimeCharacterSelectionLifecycle.EnteringWorld,
|
||
environment.Runtime.View.Snapshot.Lifecycle);
|
||
Assert.NotEqual(0u, controller.EnterWaitContext);
|
||
}
|
||
|
||
[Fact]
|
||
public void RestoreSilenceExpires_EnterTransitions_AndErrorUsesMessageDialog()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
|
||
controller.Rows[2].OnClick!();
|
||
environment.Button(CharacterManagementUiController.RestoreElementId).OnClick!();
|
||
uint restoreWait = controller.OperationWaitContext;
|
||
Assert.NotEqual(0u, restoreWait);
|
||
Assert.Equal(
|
||
RuntimeCharacterSelectionOperation.RestoreRequested,
|
||
environment.Runtime.View.Snapshot.Operation);
|
||
|
||
// ACE may send no restore response. Runtime's correlation expiry is
|
||
// represented by Operation=None; the presentation never owns a timer.
|
||
controller.Tick();
|
||
Assert.Equal(restoreWait, controller.OperationWaitContext);
|
||
environment.Runtime.SetOperation(RuntimeCharacterSelectionOperation.None);
|
||
controller.Tick();
|
||
Assert.Equal(0u, controller.OperationWaitContext);
|
||
Assert.False(environment.Dialogs.IsOpen);
|
||
|
||
controller.Rows[0].OnClick!();
|
||
environment.Button(CharacterManagementUiController.EnterElementId).OnClick!();
|
||
Assert.Equal(1, environment.Runtime.EnterCalls);
|
||
Assert.Equal(
|
||
RuntimeCharacterSelectionLifecycle.EnteringWorld,
|
||
environment.Runtime.View.Snapshot.Lifecycle);
|
||
Assert.NotEqual(0u, controller.EnterWaitContext);
|
||
Assert.Equal("Entering World", Message(
|
||
environment.LastDialog(RetailDialogType.Wait)));
|
||
|
||
environment.Runtime.SetError("That character is unavailable.");
|
||
controller.Tick();
|
||
Assert.Equal(0u, controller.EnterWaitContext);
|
||
Assert.NotEqual(0u, controller.ErrorDialogContext);
|
||
ImportedLayout error = environment.LastDialog(RetailDialogType.Message);
|
||
Assert.Equal("That character is unavailable.", Message(error));
|
||
DialogButton(error, RetailMessageDialogView.OkButtonId).OnClick!();
|
||
Assert.Equal(1, environment.Runtime.CancelCalls);
|
||
Assert.Null(environment.Runtime.View.Snapshot.Error);
|
||
Assert.False(environment.Dialogs.IsOpen);
|
||
|
||
// CharacterError.NumErrors is ignored by Runtime without a revision;
|
||
// an unchanged, error-free projection must not manufacture a dialog.
|
||
int createdBeforeSentinel = environment.DialogLayouts.Count;
|
||
controller.Tick();
|
||
Assert.Equal(createdBeforeSentinel, environment.DialogLayouts.Count);
|
||
|
||
environment.Button(CharacterManagementUiController.EnterElementId).OnClick!();
|
||
Assert.NotEqual(0u, controller.EnterWaitContext);
|
||
environment.Runtime.SetLifecycle(
|
||
RuntimeCharacterSelectionLifecycle.InWorld);
|
||
controller.Tick();
|
||
Assert.False(controller.Root.Visible);
|
||
Assert.Equal(0u, controller.EnterWaitContext);
|
||
Assert.False(environment.Dialogs.IsOpen);
|
||
}
|
||
|
||
[Fact]
|
||
public void Restore_OpensWaitBeforeCommand_AndKeepsOneModalAcrossReentrantTick()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
controller.Rows[2].OnClick!();
|
||
uint observedContext = 0u;
|
||
environment.Runtime.BeforeRestore = () =>
|
||
{
|
||
observedContext = controller.OperationWaitContext;
|
||
Assert.NotEqual(0u, observedContext);
|
||
Assert.True(environment.Dialogs.IsOpen);
|
||
Assert.Same(
|
||
environment.LastDialog(RetailDialogType.Wait).Root,
|
||
environment.Host.Modal);
|
||
|
||
// A synchronous callback can pump the presentation before the
|
||
// command has returned. The in-flight edge must retain the one
|
||
// wait context instead of closing/reopening it.
|
||
controller.Tick();
|
||
Assert.Equal(observedContext, controller.OperationWaitContext);
|
||
};
|
||
environment.Runtime.AfterRestoreProjection = () =>
|
||
{
|
||
controller.Tick();
|
||
Assert.Equal(observedContext, controller.OperationWaitContext);
|
||
};
|
||
|
||
environment.Button(
|
||
CharacterManagementUiController.RestoreElementId).OnClick!();
|
||
|
||
Assert.Equal(1, environment.Runtime.RestoreCalls);
|
||
Assert.Equal(observedContext, controller.OperationWaitContext);
|
||
Assert.Equal(
|
||
1,
|
||
environment.DialogLayouts.Count(static entry =>
|
||
entry.Type == RetailDialogType.Wait));
|
||
Assert.Same(
|
||
environment.LastDialog(RetailDialogType.Wait).Root,
|
||
environment.Host.Modal);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(false)]
|
||
[InlineData(true)]
|
||
public void Restore_ImmediateRejectionOrFailure_ClosesPreopenedWait(
|
||
bool throwFailure)
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
controller.Rows[2].OnClick!();
|
||
environment.Runtime.RestoreStatus = RuntimeCommandStatus.Rejected;
|
||
environment.Runtime.ThrowOnRestore = throwFailure;
|
||
environment.Runtime.BeforeRestore = () =>
|
||
{
|
||
Assert.NotEqual(0u, controller.OperationWaitContext);
|
||
Assert.NotNull(environment.Host.Modal);
|
||
};
|
||
|
||
Exception? error = Record.Exception(() => environment.Button(
|
||
CharacterManagementUiController.RestoreElementId).OnClick!());
|
||
|
||
Assert.Null(error);
|
||
Assert.Equal(1, environment.Runtime.RestoreCalls);
|
||
Assert.Equal(0u, controller.OperationWaitContext);
|
||
Assert.False(environment.Dialogs.IsOpen);
|
||
Assert.Null(environment.Host.Modal);
|
||
Assert.Equal(
|
||
RuntimeCharacterSelectionOperation.None,
|
||
environment.Runtime.View.Snapshot.Operation);
|
||
}
|
||
|
||
[Fact]
|
||
public void MissingOrDisposedBorrowedView_ClosesDialogsFlushesRowsAndDisposesSafely()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
environment.Button(CharacterManagementUiController.DeleteElementId).OnClick!();
|
||
Assert.NotEqual(0u, controller.DeleteDialogContext);
|
||
|
||
environment.Runtime.ProvideView = false;
|
||
controller.Tick();
|
||
|
||
Assert.False(controller.Root.Visible);
|
||
Assert.Empty(controller.Rows);
|
||
Assert.False(environment.Dialogs.IsOpen);
|
||
Assert.Equal(0, environment.Runtime.CancelCalls);
|
||
|
||
controller.Dispose();
|
||
Assert.Null(controller.Root.Parent);
|
||
Assert.Null(environment.Button(
|
||
CharacterManagementUiController.EnterElementId).OnClick);
|
||
controller.Tick();
|
||
}
|
||
|
||
[Fact]
|
||
public void SessionReset_ClosesOwnedContextsWithoutReentrantCancel()
|
||
{
|
||
using var environment = new EnvironmentHarness();
|
||
CharacterManagementUiController controller = environment.Controller;
|
||
environment.Button(CharacterManagementUiController.DeleteElementId).OnClick!();
|
||
Assert.NotEqual(0u, controller.DeleteDialogContext);
|
||
|
||
controller.ResetSession();
|
||
|
||
Assert.False(controller.Root.Visible);
|
||
Assert.Empty(controller.Rows);
|
||
Assert.False(environment.Dialogs.IsOpen);
|
||
Assert.Equal(0, environment.Runtime.CancelCalls);
|
||
}
|
||
|
||
[Fact]
|
||
public void PreviewViewport_IsRejectedBeforeTheAuthoredScreenIsMounted()
|
||
{
|
||
var host = new UiRoot { Width = 800f, Height = 600f };
|
||
ImportedLayout screen = BuildScreen(includePreview: true);
|
||
using var dialogs = new RetailDialogFactory(
|
||
host,
|
||
RetailDialogFactoryTests.BuildDialogLayout);
|
||
var runtime = new FakeRuntime();
|
||
|
||
CharacterManagementUiController? controller =
|
||
CharacterManagementUiController.Bind(
|
||
host,
|
||
screen,
|
||
static (_, _) => BuildRow(),
|
||
dialogs,
|
||
runtime.Bindings,
|
||
TestStrings());
|
||
|
||
Assert.Null(controller);
|
||
Assert.Empty(host.Children);
|
||
}
|
||
|
||
[Fact]
|
||
public void TransientTemplateMiss_DoesNotConsumeTheRuntimeRevision()
|
||
{
|
||
var host = new UiRoot { Width = 800f, Height = 600f };
|
||
ImportedLayout screen = BuildScreen();
|
||
using var dialogs = new RetailDialogFactory(
|
||
host,
|
||
RetailDialogFactoryTests.BuildDialogLayout);
|
||
var runtime = new FakeRuntime();
|
||
int resolveCalls = 0;
|
||
using CharacterManagementUiController controller =
|
||
Assert.IsType<CharacterManagementUiController>(
|
||
CharacterManagementUiController.Bind(
|
||
host,
|
||
screen,
|
||
(_, _) => ++resolveCalls == 1 ? null : BuildRow(),
|
||
dialogs,
|
||
runtime.Bindings,
|
||
TestStrings()));
|
||
|
||
Assert.Empty(controller.Rows);
|
||
controller.Tick();
|
||
|
||
Assert.Equal(3, controller.Rows.Count);
|
||
Assert.True(resolveCalls >= 4);
|
||
}
|
||
|
||
private static CharacterManagementUiController.DialogStrings TestStrings() =>
|
||
new(
|
||
name => $"WARNING! {name}\nType DELETE in the box below.",
|
||
"DELETE",
|
||
"Please Wait",
|
||
"Entering World",
|
||
"Are you sure you want to leave?");
|
||
|
||
private static void AssertDetachedAndUnbound(ImportedLayout screen)
|
||
{
|
||
Assert.Null(screen.Root.Parent);
|
||
Assert.Null(Assert.IsType<UiButton>(screen.FindElement(
|
||
CharacterManagementUiController.EnterElementId)).OnClick);
|
||
Assert.Null(Assert.IsType<UiButton>(screen.FindElement(
|
||
CharacterManagementUiController.DeleteElementId)).OnClick);
|
||
Assert.Null(Assert.IsType<UiButton>(screen.FindElement(
|
||
CharacterManagementUiController.RestoreElementId)).OnClick);
|
||
}
|
||
|
||
private static ImportedLayout BuildScreen(bool includePreview = false)
|
||
{
|
||
var root = new ElementInfo
|
||
{
|
||
Id = CharacterManagementUiController.RootElementId,
|
||
Type = 3u,
|
||
Width = 800f,
|
||
Height = 600f,
|
||
};
|
||
var list = new ElementInfo
|
||
{
|
||
Id = CharacterManagementUiController.ListElementId,
|
||
Type = 5u,
|
||
X = 42f,
|
||
Y = 212f,
|
||
Width = 160f,
|
||
Height = 320f,
|
||
};
|
||
list.TemplateList.Add(new UiTemplateListEntry(
|
||
0x21000004u,
|
||
0x100003A5u));
|
||
root.Children.Add(list);
|
||
root.Children.Add(new ElementInfo
|
||
{
|
||
Id = CharacterManagementUiController.WorldTextElementId,
|
||
Type = 12u,
|
||
X = 21f,
|
||
Y = 44f,
|
||
Width = 193f,
|
||
Height = 110f,
|
||
});
|
||
root.Children.Add(ButtonInfo(
|
||
CharacterManagementUiController.CreateElementId));
|
||
root.Children.Add(ButtonInfo(
|
||
CharacterManagementUiController.EnterElementId));
|
||
root.Children.Add(ButtonInfo(
|
||
CharacterManagementUiController.DeleteElementId));
|
||
root.Children.Add(ButtonInfo(
|
||
CharacterManagementUiController.RestoreElementId));
|
||
root.Children.Add(ButtonInfo(
|
||
CharacterManagementUiController.CreditsElementId));
|
||
root.Children.Add(ButtonInfo(
|
||
CharacterManagementUiController.ExitElementId));
|
||
if (includePreview)
|
||
{
|
||
root.Children.Add(new ElementInfo
|
||
{
|
||
Id = 0xDEADBEEFu,
|
||
Type = 0xDu,
|
||
Width = 100f,
|
||
Height = 100f,
|
||
});
|
||
}
|
||
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
|
||
}
|
||
|
||
private static ElementInfo ButtonInfo(uint id) => new()
|
||
{
|
||
Id = id,
|
||
Type = 1u,
|
||
Width = 100f,
|
||
Height = 30f,
|
||
};
|
||
|
||
private static UiElement BuildRow() => LayoutImporter.Build(
|
||
new ElementInfo
|
||
{
|
||
Id = 0x100003A5u,
|
||
Type = 1u,
|
||
Width = 160f,
|
||
Height = 16f,
|
||
},
|
||
_ => (0u, 0, 0),
|
||
null).Root;
|
||
|
||
private static IEnumerable<UiElement> Descendants(UiElement root)
|
||
{
|
||
yield return root;
|
||
foreach (UiElement child in root.Children)
|
||
foreach (UiElement descendant in Descendants(child))
|
||
yield return descendant;
|
||
}
|
||
|
||
private static UiButton DialogButton(ImportedLayout layout, uint id) =>
|
||
Assert.IsType<UiButton>(layout.FindElement(id));
|
||
|
||
private static UiField Input(ImportedLayout layout) =>
|
||
Assert.IsType<UiField>(layout.FindElement(
|
||
RetailConfirmationTextInputDialogView.InputElementId));
|
||
|
||
private static string Message(ImportedLayout layout) => string.Join(
|
||
" ",
|
||
Assert.IsType<UiText>(layout.FindElement(0x3Eu))
|
||
.LinesProvider()
|
||
.Select(static line => line.Text));
|
||
|
||
private sealed class EnvironmentHarness : IDisposable
|
||
{
|
||
public EnvironmentHarness()
|
||
{
|
||
Host = new UiRoot { Width = 800f, Height = 600f };
|
||
Screen = BuildScreen();
|
||
Runtime = new FakeRuntime();
|
||
Dialogs = new RetailDialogFactory(Host, type =>
|
||
{
|
||
ImportedLayout layout =
|
||
RetailDialogFactoryTests.BuildDialogLayout(type);
|
||
DialogLayouts.Add((type, layout));
|
||
return layout;
|
||
});
|
||
Controller = Assert.IsType<CharacterManagementUiController>(
|
||
CharacterManagementUiController.Bind(
|
||
Host,
|
||
Screen,
|
||
static (_, _) => BuildRow(),
|
||
Dialogs,
|
||
Runtime.Bindings,
|
||
TestStrings()));
|
||
}
|
||
|
||
public UiRoot Host { get; }
|
||
public ImportedLayout Screen { get; }
|
||
public FakeRuntime Runtime { get; }
|
||
public RetailDialogFactory Dialogs { get; }
|
||
public List<(RetailDialogType Type, ImportedLayout Layout)> DialogLayouts { get; } = [];
|
||
public CharacterManagementUiController Controller { get; }
|
||
|
||
public UiButton Button(uint id) =>
|
||
Assert.IsType<UiButton>(Screen.FindElement(id));
|
||
|
||
public ImportedLayout LastDialog(RetailDialogType type) =>
|
||
DialogLayouts.Last(entry => entry.Type == type).Layout;
|
||
|
||
public void Dispose()
|
||
{
|
||
Controller.Dispose();
|
||
Dialogs.Dispose();
|
||
}
|
||
}
|
||
|
||
private sealed class FakeRuntime
|
||
{
|
||
private static readonly RuntimeGenerationToken Generation = new(7u);
|
||
|
||
public FakeRuntime()
|
||
{
|
||
View.Entries =
|
||
[
|
||
new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u),
|
||
new RuntimeCharacterSelectionEntry(1, 0x50000002u, "Zulu", 0u),
|
||
new RuntimeCharacterSelectionEntry(2, 0x50000003u, "Aaron (pending)", 1u),
|
||
];
|
||
View.Snapshot = Snapshot(
|
||
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
|
||
revision: 1,
|
||
highlightedCharacterId: 0x50000001u,
|
||
buttons: ButtonsFor(0x50000001u));
|
||
Bindings = new CharacterSelectionRuntimeBindings(
|
||
() => ProvideView ? View : null,
|
||
Highlight,
|
||
Enter,
|
||
RequestDelete,
|
||
ConfirmDelete,
|
||
Restore,
|
||
Cancel,
|
||
RequestExit);
|
||
}
|
||
|
||
public FakeView View { get; } = new();
|
||
public CharacterSelectionRuntimeBindings Bindings { get; }
|
||
public bool ProvideView { get; set; } = true;
|
||
public int HighlightCalls { get; private set; }
|
||
public int EnterCalls { get; private set; }
|
||
public int ConfirmDeleteCalls { get; private set; }
|
||
public int CancelCalls { get; private set; }
|
||
public int RestoreCalls { get; private set; }
|
||
public int RequestExitCalls { get; private set; }
|
||
public RuntimeCommandStatus RestoreStatus { get; set; } =
|
||
RuntimeCommandStatus.Accepted;
|
||
public bool ThrowOnRestore { get; set; }
|
||
public Action? BeforeRestore { get; set; }
|
||
public Action? AfterRestoreProjection { get; set; }
|
||
|
||
public void SetOperation(RuntimeCharacterSelectionOperation operation)
|
||
{
|
||
RuntimeCharacterSelectionButtons buttons = operation is
|
||
RuntimeCharacterSelectionOperation.DeleteRequested
|
||
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
||
? RuntimeCharacterSelectionButtons.None
|
||
: ButtonsFor(View.Snapshot.HighlightedCharacterId);
|
||
Update(snapshot => snapshot with
|
||
{
|
||
Operation = operation,
|
||
Buttons = buttons,
|
||
});
|
||
}
|
||
|
||
public void ReplaceRoster(
|
||
RuntimeCharacterSelectionEntry[] entries,
|
||
uint highlightedCharacterId)
|
||
{
|
||
View.Entries = entries;
|
||
Update(snapshot => snapshot with
|
||
{
|
||
RosterCount = entries.Length,
|
||
HighlightedCharacterId = highlightedCharacterId,
|
||
HighlightedDisplayIndex = Array.FindIndex(
|
||
entries,
|
||
entry => entry.CharacterId == highlightedCharacterId),
|
||
PendingDeleteCharacterId = 0u,
|
||
Operation = RuntimeCharacterSelectionOperation.None,
|
||
Buttons = ButtonsFor(highlightedCharacterId),
|
||
});
|
||
}
|
||
|
||
public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle) =>
|
||
Update(snapshot => snapshot with { Lifecycle = lifecycle });
|
||
|
||
public void SetWorldName(string worldName) =>
|
||
Update(snapshot => snapshot with { WorldName = worldName });
|
||
|
||
public void SetError(string message) => Update(snapshot => snapshot with
|
||
{
|
||
Lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection,
|
||
Error = new RuntimeCharacterSelectionError(
|
||
1u,
|
||
AcDream.Core.Net.Messages.CharacterError.Code.Logon,
|
||
message),
|
||
PendingDeleteCharacterId = 0u,
|
||
Operation = RuntimeCharacterSelectionOperation.None,
|
||
});
|
||
|
||
private RuntimeCommandResult Highlight(uint characterId)
|
||
{
|
||
HighlightCalls++;
|
||
int index = Array.FindIndex(
|
||
View.Entries,
|
||
entry => entry.CharacterId == characterId);
|
||
if (index < 0)
|
||
return Result(RuntimeCommandStatus.Rejected);
|
||
Update(snapshot => snapshot with
|
||
{
|
||
HighlightedCharacterId = characterId,
|
||
HighlightedDisplayIndex = index,
|
||
Buttons = ButtonsFor(characterId),
|
||
});
|
||
return Result(RuntimeCommandStatus.Accepted, characterId);
|
||
}
|
||
|
||
private RuntimeCommandResult Enter()
|
||
{
|
||
EnterCalls++;
|
||
Update(snapshot => snapshot with
|
||
{
|
||
Lifecycle = RuntimeCharacterSelectionLifecycle.EnteringWorld,
|
||
Error = null,
|
||
});
|
||
return Result(
|
||
RuntimeCommandStatus.Accepted,
|
||
View.Snapshot.HighlightedCharacterId);
|
||
}
|
||
|
||
private RuntimeCommandResult RequestDelete()
|
||
{
|
||
uint id = View.Snapshot.HighlightedCharacterId;
|
||
Update(snapshot => snapshot with
|
||
{
|
||
PendingDeleteCharacterId = id,
|
||
Error = null,
|
||
});
|
||
return Result(RuntimeCommandStatus.Accepted, id);
|
||
}
|
||
|
||
private RuntimeCommandResult ConfirmDelete()
|
||
{
|
||
ConfirmDeleteCalls++;
|
||
uint id = View.Snapshot.HighlightedCharacterId;
|
||
Update(snapshot => snapshot with
|
||
{
|
||
PendingDeleteCharacterId = 0u,
|
||
Operation = RuntimeCharacterSelectionOperation.DeleteRequested,
|
||
Buttons = RuntimeCharacterSelectionButtons.None,
|
||
});
|
||
return Result(RuntimeCommandStatus.Accepted, id);
|
||
}
|
||
|
||
private RuntimeCommandResult Restore()
|
||
{
|
||
RestoreCalls++;
|
||
uint id = View.Snapshot.HighlightedCharacterId;
|
||
BeforeRestore?.Invoke();
|
||
if (ThrowOnRestore)
|
||
throw new InvalidOperationException("restore transport failed");
|
||
if (RestoreStatus != RuntimeCommandStatus.Accepted)
|
||
return Result(RestoreStatus, id);
|
||
Update(snapshot => snapshot with
|
||
{
|
||
LastRestoreRequestedCharacterId = id,
|
||
Operation = RuntimeCharacterSelectionOperation.RestoreRequested,
|
||
Buttons = new RuntimeCharacterSelectionButtons(
|
||
false,
|
||
false,
|
||
false,
|
||
false,
|
||
true),
|
||
});
|
||
AfterRestoreProjection?.Invoke();
|
||
return Result(RuntimeCommandStatus.Accepted, id);
|
||
}
|
||
|
||
private RuntimeCommandResult Cancel()
|
||
{
|
||
CancelCalls++;
|
||
Update(snapshot => snapshot with
|
||
{
|
||
PendingDeleteCharacterId = 0u,
|
||
Error = null,
|
||
Buttons = ButtonsFor(snapshot.HighlightedCharacterId),
|
||
});
|
||
return Result(RuntimeCommandStatus.Accepted);
|
||
}
|
||
|
||
private void RequestExit() => RequestExitCalls++;
|
||
|
||
private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId)
|
||
{
|
||
RuntimeCharacterSelectionEntry? selected = View.Entries
|
||
.Cast<RuntimeCharacterSelectionEntry?>()
|
||
.FirstOrDefault(entry => entry?.CharacterId == characterId);
|
||
if (selected is null)
|
||
return RuntimeCharacterSelectionButtons.None;
|
||
if (selected.Value.IsPendingDelete)
|
||
{
|
||
return new RuntimeCharacterSelectionButtons(
|
||
false,
|
||
false,
|
||
true,
|
||
false,
|
||
true);
|
||
}
|
||
return new RuntimeCharacterSelectionButtons(
|
||
true,
|
||
true,
|
||
false,
|
||
true,
|
||
false);
|
||
}
|
||
|
||
private void Update(
|
||
Func<RuntimeCharacterSelectionSnapshot,
|
||
RuntimeCharacterSelectionSnapshot> update)
|
||
{
|
||
RuntimeCharacterSelectionSnapshot current = View.Snapshot;
|
||
RuntimeCharacterSelectionSnapshot next = update(current);
|
||
View.Snapshot = next with { Revision = current.Revision + 1 };
|
||
}
|
||
|
||
private RuntimeCharacterSelectionSnapshot Snapshot(
|
||
RuntimeCharacterSelectionLifecycle lifecycle,
|
||
long revision,
|
||
uint highlightedCharacterId,
|
||
RuntimeCharacterSelectionButtons buttons) => new(
|
||
Generation,
|
||
lifecycle,
|
||
revision,
|
||
"account",
|
||
SlotCount: 5,
|
||
RosterCount: View.Entries.Length,
|
||
WorldName: "sawato",
|
||
highlightedCharacterId,
|
||
HighlightedDisplayIndex: Array.FindIndex(
|
||
View.Entries,
|
||
entry => entry.CharacterId == highlightedCharacterId),
|
||
PendingDeleteCharacterId: 0u,
|
||
LastRestoreRequestedCharacterId: 0u,
|
||
Operation: RuntimeCharacterSelectionOperation.None,
|
||
Error: null,
|
||
buttons);
|
||
|
||
private static RuntimeCommandResult Result(
|
||
RuntimeCommandStatus status,
|
||
uint objectId = 0u) => new(status, Generation, objectId);
|
||
}
|
||
|
||
private sealed class FakeView : IRuntimeCharacterSelectionView
|
||
{
|
||
public RuntimeCharacterSelectionEntry[] Entries { get; set; } = [];
|
||
public RuntimeCharacterSelectionSnapshot Snapshot { get; set; }
|
||
|
||
public bool TryGetAt(
|
||
int displayIndex,
|
||
out RuntimeCharacterSelectionEntry character)
|
||
{
|
||
if ((uint)displayIndex >= (uint)Entries.Length)
|
||
{
|
||
character = default;
|
||
return false;
|
||
}
|
||
character = Entries[displayIndex];
|
||
return true;
|
||
}
|
||
|
||
public bool TryGet(
|
||
uint characterId,
|
||
out RuntimeCharacterSelectionEntry character)
|
||
{
|
||
int index = Array.FindIndex(
|
||
Entries,
|
||
entry => entry.CharacterId == characterId);
|
||
if (index < 0)
|
||
{
|
||
character = default;
|
||
return false;
|
||
}
|
||
character = Entries[index];
|
||
return true;
|
||
}
|
||
|
||
public void Visit(IRuntimeCharacterSelectionVisitor visitor)
|
||
{
|
||
foreach (RuntimeCharacterSelectionEntry character in Entries)
|
||
visitor.Visit(in character);
|
||
}
|
||
|
||
public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) =>
|
||
NoopDisposable.Instance;
|
||
}
|
||
|
||
private sealed class NoopDisposable : IDisposable
|
||
{
|
||
public static NoopDisposable Instance { get; } = new();
|
||
public void Dispose() { }
|
||
}
|
||
}
|