Finding 1 (Exit button dead): retail's gmCharacterManagementUI Exit
button (element 0x100003A4, offset 7 from the listbox base in
ListenToElementMessage@0x004ed5a0) opens MakeConfirmExitDialog
(0x004ed250), whose exact ID_CharacterManagement_ConfirmExit text
(table 0x23000002) and m_confirmExitDialogContext re-entry guard are
now ported. On confirm (matching RecvNotice_CloseDialog@0x004ed760
case 1's ConfirmationResult check) the client exits through the
EXISTING graceful window-close seam (CharacterSelectionRuntimeBindings
.RequestExit -> d.Window.Close, the same delegate
GameplayInputCommandController's Escape fallback already uses) so
disconnected/exited status events still fire via GameWindow.OnClosing
-> CompleteShutdown. Retail's real post-confirm destination is
QueueUIMode(0x10000009) -> gmEpilogueUI, an epilogue screen this round
does not port — recorded as AD-99. Credits (element 0x100003A3,
QueueUIMode(0x10000005) -> gmCreditsUI) stays visibly ghosted like
Create, same treatment, out of scope this round.
Finding 2 (row names center-aligned, retail is left): the character
row template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT
confirmed HJustify=Left with three stateful Type-3 highlight-art
children and no Type-12 caption child) authors its OWN justify
directly, with no separate text child to lift a label from.
DatWidgetFactory.BuildButton's Left-justify branch required
!ReferenceEquals(labelInfo, info) — true only when a label was LIFTED
from a distinct child — so a button's own direct HJustify=Left was
silently dropped to UiButton's Center default. Widened the branch to
also honor the direct case, preserving the existing lifted-child
LabelOffsetX behavior and leaving genuinely-centered buttons
(CREATE/ENTER/DELETE/RESTORE) untouched.
Finding 3 (World box empty): parsed ACE's GameMessageServerName
(opcode 0xF7E1, ACE.Server/Network/GameMessages/Messages/
GameMessageServerName.cs; retail CM_Login::DispatchUI_WorldInfo
@0x006ad860 -> ClientUISystem::Handle_Login__WorldInfo@0x005641a0 ->
ECM_Login::SendNotice_WorldName@0x00692b10, notice 0x186a2, consumed
by gmCharacterManagementUI::UpdateWorldName@0x004ec120 /
RecvNotice_WorldName@0x004ec360 onto element 0x1000039B) as
src/AcDream.Core.Net/Messages/ServerName.cs, cross-checked against
holtburger's ServerNameData. WorldSession.ServerNameReceived fires
alongside CharacterListReceived (ACE sends both in one
SendConnectResponse batch); RuntimeCharacterSelectionState.
ApplyWorldName is the new J-owner field (ungated by lifecycle, since
either message can arrive first); CharacterManagementUiController
binds it onto the WorldTextElementId UiText. Per the LA1 status
vocabulary, the characterList STATUS event's worldName field is
intentionally NOT added this round (kept bounded to the client-side
fix) — a follow-up if the launcher UI wants it.
Also corrects AD-44, discovered stale while filing AD-99: its opening
claim ("acdream has no retained character-management screen") was
false as of this session — LA7/LA8 shipped the screen in earlier
commits without updating this row.
Tests: exit-confirm open/cancel/confirm/re-entry-guard flow;
DatWidgetFactory own-HJustify-Left/Center regression tests plus the
live-DAT pinned row-justify assertion; ServerName parse round-trip
(byte-exact vs ACE's AceWireWriter fixture, truncation/wrong-opcode
cases); WorldSession dispatch test (roster+world in one wire batch);
RuntimeCharacterSelectionState.ApplyWorldName tests (order-independent
of ApplyRoster, unchanged-value no-op, Reset clears); controller test
binding the World text element to the live snapshot. Extended the
shared RetailDialogFactoryTests.BuildDialogLayout test fixture with a
Confirmation-type branch (Accept/Reject buttons) since this is its
first RetailDialogType.Confirmation consumer.
Suites: full solution Release build green; AcDream.App.Tests 5100/6
skips, AcDream.Core.Net.Tests 965/0, AcDream.Runtime.Tests 1665/0, all
Release, 0 failures; live-DAT probes (ACDREAM_PROBE_LIVE_MOUNT=1)
green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1149 lines
43 KiB
C#
1149 lines
43 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);
|
||
|
||
exit.OnClick!();
|
||
|
||
Assert.NotEqual(0u, controller.ConfirmExitDialogContext);
|
||
ImportedLayout dialog = environment.LastDialog(RetailDialogType.Confirmation);
|
||
Assert.Equal(
|
||
"Are you sure you want to leave?",
|
||
Message(dialog));
|
||
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() { }
|
||
}
|
||
}
|