Implement retail character management screen
This commit is contained in:
parent
5535d0adac
commit
6cfab727f1
19 changed files with 2435 additions and 6 deletions
|
|
@ -157,6 +157,16 @@ window registration, plugin mounts, cursor feedback, layout persistence, and the
|
||||||
retained tick/draw/restore/dispose paths. Panel-specific construction must not
|
retained tick/draw/restore/dispose paths. Panel-specific construction must not
|
||||||
move back into `GameWindow.OnLoad`.
|
move back into `GameWindow.OnLoad`.
|
||||||
|
|
||||||
|
The graphical no-selector launch projects Runtime's sole
|
||||||
|
`RuntimeCharacterSelectionState` through the retained character-management root
|
||||||
|
resolved from DAT enum table 5 (`0x10000005` -> `0x21000004`, selected root
|
||||||
|
`0x1000039A`). App borrows the view and routes generation-capturing typed
|
||||||
|
commands; it owns no roster, highlight, operation, error, or lifecycle mirror.
|
||||||
|
The authored screen is a flat ListBox and buttons, with the shared retail dialog
|
||||||
|
catalog for confirmation, wait, and error presentation. It contains no viewport
|
||||||
|
or character preview. Explicit-selector graphical launches and no-window hosts
|
||||||
|
do not mount this presentation.
|
||||||
|
|
||||||
Magic follows the same boundary. Core `Spellbook` is the one learned/favorite/
|
Magic follows the same boundary. Core `Spellbook` is the one learned/favorite/
|
||||||
desired/enchantment state projection; Core.Net owns exact manifest and live
|
desired/enchantment state projection; Core.Net owns exact manifest and live
|
||||||
message parsing; Runtime `RuntimeActionState.SpellCast` owns validated cast
|
message parsing; Runtime `RuntimeActionState.SpellCast` owns validated cast
|
||||||
|
|
|
||||||
104
docs/research/2026-08-14-la8-character-management-ui.md
Normal file
104
docs/research/2026-08-14-la8-character-management-ui.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# LA8 retained character-management UI evidence
|
||||||
|
|
||||||
|
Date: 2026-08-14
|
||||||
|
|
||||||
|
This note records the retail and installed-DAT evidence for Campaign LA slice
|
||||||
|
LA8, plus the exact ownership and presentation boundary implemented by the
|
||||||
|
slice. LA7b remains the authority for pre-world Runtime and wire behavior.
|
||||||
|
|
||||||
|
## Named-retail evidence
|
||||||
|
|
||||||
|
The implementation was derived from
|
||||||
|
`docs/research/named-retail/acclient_2013_pseudo_c.txt` and the corresponding
|
||||||
|
`acclient.h` definition before the screen was written.
|
||||||
|
|
||||||
|
- `DBObj::GetDIDByEnum` (`0x004153A0`) forwards to
|
||||||
|
`DBCache::GetDIDFromEnumStatic`; retail resolves the category/table mapping
|
||||||
|
before loading a LayoutDesc.
|
||||||
|
- `gmCharacterManagementUI::gmCharacterManagementUI` (`0x004EC8F0`) calls
|
||||||
|
`UIMainFramework::CreateAndAddRootElement(0x10000005, 0x1000039A)`, then
|
||||||
|
binds ListBox `0x1000039D`, Create `0x100003A0`, Enter `0x100003A2`, Delete
|
||||||
|
`0x1000039F`, and Restore `0x1000039E`.
|
||||||
|
- The verbatim header at `acclient.h:56545` declares exactly that ListBox,
|
||||||
|
those four button pointers, the selected row/guid, and four dialog contexts.
|
||||||
|
It declares no viewport, `gmCG3DView`, or preview owner.
|
||||||
|
- `RebuildCharacterList` (`0x004EC3A0`) creates each row through
|
||||||
|
`AddItemFromTemplateList`, retains character identity, displays pending
|
||||||
|
deletion in red, sorts by ordinal name, moves greyed entries to the tail,
|
||||||
|
and restores/falls back selection. LA8 preserves the already canonical LA7b
|
||||||
|
display order and identity instead of sorting an App copy.
|
||||||
|
- `SelectCharacter` (`0x004EC160`) and `UpdateButtons` (`0x004EC240`) establish
|
||||||
|
the highlight and button matrix: no or greyed selection disables Enter and
|
||||||
|
Delete; an active selection shows/enables Delete; a greyed selection hides
|
||||||
|
Delete and shows/enables Restore.
|
||||||
|
- `ListenToElementMessage` (`0x004ED5A0`) routes the list selection message,
|
||||||
|
button clicks, and row-template `0x100003A5` activation message `0x1A`.
|
||||||
|
Double-activating a row calls `EnterGame` (`0x004ED440`).
|
||||||
|
- `MakeDeleteCharacterConfirmationDialog` (`0x004ECCA0`) uses retail dialog
|
||||||
|
type 5 and compares the typed response with the localized DELETE response
|
||||||
|
case-insensitively. `MakePleaseWaitDialog` (`0x004ECED0`) and
|
||||||
|
`MakeEnteringWorldDialog` (`0x004ED090`) use the wait machinery. Error
|
||||||
|
presentation enters through `MakeErrorMessageDialog` (`0x004ECB10`). The
|
||||||
|
destructor (`0x004EC080`) closes every owned dialog context.
|
||||||
|
|
||||||
|
The shared dialog factory switch supplies catalog roots/classes used here:
|
||||||
|
message type 3 is root `0x24` / class `0x17`; confirmation-text-input type 5
|
||||||
|
is root `0x2C` / class `0x15`; the existing wait type 2 is root `0x31` /
|
||||||
|
class `0x19`. The message button is `0x26`. Type 5 uses field `0x2C`, accept
|
||||||
|
`0x2E`, reject `0x2F`, and result property `0x9C`.
|
||||||
|
|
||||||
|
## Installed-DAT proof
|
||||||
|
|
||||||
|
The permanent read-only acceptance probe is
|
||||||
|
`tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs`. Run it
|
||||||
|
with `ACDREAM_PROBE_LIVE_MOUNT=1`; it reads the ordinary
|
||||||
|
`%USERPROFILE%/Documents/Asheron's Call` DAT set unless `ACDREAM_DAT_DIR`
|
||||||
|
overrides the location. It uses production `DatCollection`,
|
||||||
|
`RetailDataIdResolver`, and `LayoutImporter`; it does not write the DATs.
|
||||||
|
|
||||||
|
The installed September-2013 data proves:
|
||||||
|
|
||||||
|
- enum category/table 5 maps `0x10000005` to concrete LayoutDesc DID
|
||||||
|
**`0x21000004`**;
|
||||||
|
- selected root `0x1000039A` is 800 x 600 with eight authored children;
|
||||||
|
- the root itself authors image media `0x06007576`; that proves a retained
|
||||||
|
layout asset, not a separate render-loop background scene;
|
||||||
|
- its ListBox template is `{ 0x21000004, 0x100003A5 }`;
|
||||||
|
- the template is a 160 x 16 `UiButton`, font `0x40000009`, with Normal,
|
||||||
|
NormalRollover, NormalPressed, Highlight, HighlightRollover, and the authored
|
||||||
|
`0xFFFFFFFF` default state;
|
||||||
|
- the authored captions are Create Character, ENTER, DELETE, and RESTORE;
|
||||||
|
- neither the selected root nor any descendant is a `UiViewport`;
|
||||||
|
- enum-table-5 dialog key 2 maps to catalog DID `0x2100003C`, containing the
|
||||||
|
type-3 and type-5 roots/children above;
|
||||||
|
- string table `0x23000002` contains DELETE, Please Wait, Entering World, and
|
||||||
|
the delete-confirmation template. The template has the PLAYER variable and
|
||||||
|
resolves it into the selected character name.
|
||||||
|
|
||||||
|
## Ownership, composition, and lifecycle
|
||||||
|
|
||||||
|
`RetailUiRuntime` imports the exact enum-resolved root only for a graphical
|
||||||
|
launch with no explicit character selector. Its focused binding borrows
|
||||||
|
`IRuntimeCharacterSelectionView`; every highlight, enter, delete-request,
|
||||||
|
delete-confirm, restore, and cancel action crosses the existing deferred
|
||||||
|
adapter as a generation-capturing Runtime command. App retains no gameplay
|
||||||
|
mirror. Explicit-selector graphical launches keep their existing flow, and
|
||||||
|
headless does not compose this App presentation.
|
||||||
|
|
||||||
|
The controller instantiates the authored row template in Runtime display
|
||||||
|
order, projects red pending-delete rows and the exact button matrix, and opens
|
||||||
|
the shared retail dialogs. Delete wait survives the opcode-only acknowledgement
|
||||||
|
until the fresh roster arrives. Restore is fire-and-observe: a silent ACE
|
||||||
|
no-reply ends only when Runtime expires its correlation. Entering-world wait
|
||||||
|
opens before the existing synchronous Enter command; error, reset, reconnect,
|
||||||
|
missing/displaced adapter, and disposal close owned contexts without re-entrant
|
||||||
|
commands. A failed transient row-template import leaves the Runtime revision
|
||||||
|
unconsumed and retries on the next frame.
|
||||||
|
|
||||||
|
There is deliberately no 3D preview and no claimed character-select background
|
||||||
|
scene. The screen root remains neutral with respect to render-loop background
|
||||||
|
composition. LA11's user visual gate owns that unresolved visual choice, plus
|
||||||
|
the live local-ACE delete/restore check. Because Enter currently completes its
|
||||||
|
established ServerReady transaction synchronously, LA11 must also verify that
|
||||||
|
the entering-world wait is perceptible on the real frame path; this slice does
|
||||||
|
not introduce a second queue or lifecycle owner merely to force a paint.
|
||||||
|
|
@ -21,6 +21,7 @@ using AcDream.Core.Selection;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.UI.Abstractions.Input;
|
using AcDream.UI.Abstractions.Input;
|
||||||
using AcDream.UI.Abstractions.Panels.Chat;
|
using AcDream.UI.Abstractions.Panels.Chat;
|
||||||
using AcDream.UI.Abstractions.Panels.Vitals;
|
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||||
|
|
@ -937,7 +938,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
(action, held) =>
|
(action, held) =>
|
||||||
d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
|
d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
|
||||||
late.Automation),
|
late.Automation),
|
||||||
Keyboard: new KeyboardRuntimeBindings(d.InputDispatcher, d.KeyBindingsFilePath));
|
Keyboard: new KeyboardRuntimeBindings(
|
||||||
|
d.InputDispatcher,
|
||||||
|
d.KeyBindingsFilePath),
|
||||||
|
CharacterSelection: d.Options.LiveCharacterSelector is null
|
||||||
|
? new CharacterSelectionRuntimeBindings(
|
||||||
|
() => late.GameRuntime.CharacterSelection,
|
||||||
|
late.GameRuntime.CharacterSelectionHighlight,
|
||||||
|
late.GameRuntime.CharacterSelectionEnter,
|
||||||
|
late.GameRuntime.CharacterSelectionRequestDelete,
|
||||||
|
late.GameRuntime.CharacterSelectionConfirmDelete,
|
||||||
|
late.GameRuntime.CharacterSelectionRestore,
|
||||||
|
late.GameRuntime.CharacterSelectionCancel)
|
||||||
|
: null);
|
||||||
RetailUiRuntime runtime = lease.Mount(
|
RetailUiRuntime runtime = lease.Mount(
|
||||||
() => RetailUiRuntime.CreateUninitialized(bindings));
|
() => RetailUiRuntime.CreateUninitialized(bindings));
|
||||||
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
|
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.UI.Abstractions;
|
using AcDream.UI.Abstractions;
|
||||||
using Silk.NET.Windowing;
|
using Silk.NET.Windowing;
|
||||||
|
|
||||||
|
|
@ -37,6 +38,24 @@ internal sealed class DeferredGameRuntimeStateCommands
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Borrows the current adapter's character-selection projection. The
|
||||||
|
/// reference is deliberately not cached here: releasing or displacing the
|
||||||
|
/// exact late binding makes the next read return <see langword="null"/>,
|
||||||
|
/// while <c>CurrentGameRuntimeAdapter</c> keeps an already-borrowed reference
|
||||||
|
/// inert if disposal races the render-thread consumer.
|
||||||
|
/// </summary>
|
||||||
|
public IRuntimeCharacterSelectionView? CharacterSelection
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return !_deactivated && _view is not null
|
||||||
|
? _view.CharacterSelection
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public IDisposable Bind(
|
public IDisposable Bind(
|
||||||
IGameRuntimeView view,
|
IGameRuntimeView view,
|
||||||
IGameRuntimeCommands commands)
|
IGameRuntimeCommands commands)
|
||||||
|
|
@ -118,6 +137,35 @@ internal sealed class DeferredGameRuntimeStateCommands
|
||||||
generation,
|
generation,
|
||||||
new RuntimeAdvancementCommand(kind, statId, cost)));
|
new RuntimeAdvancementCommand(kind, statId, cost)));
|
||||||
|
|
||||||
|
// Campaign LA slice LA8: the retained character-management screen uses
|
||||||
|
// the same generation-capturing late seam as every gameplay panel. The
|
||||||
|
// screen never receives GameRuntime or WorldSession and cannot retain a
|
||||||
|
// stale generation across reconnect.
|
||||||
|
|
||||||
|
public RuntimeCommandResult CharacterSelectionHighlight(uint characterId) =>
|
||||||
|
Invoke((commands, generation) =>
|
||||||
|
commands.CharacterSelection.Highlight(generation, characterId));
|
||||||
|
|
||||||
|
public RuntimeCommandResult CharacterSelectionEnter() =>
|
||||||
|
Invoke((commands, generation) =>
|
||||||
|
commands.CharacterSelection.Enter(generation));
|
||||||
|
|
||||||
|
public RuntimeCommandResult CharacterSelectionRequestDelete() =>
|
||||||
|
Invoke((commands, generation) =>
|
||||||
|
commands.CharacterSelection.RequestDelete(generation));
|
||||||
|
|
||||||
|
public RuntimeCommandResult CharacterSelectionConfirmDelete() =>
|
||||||
|
Invoke((commands, generation) =>
|
||||||
|
commands.CharacterSelection.ConfirmDelete(generation));
|
||||||
|
|
||||||
|
public RuntimeCommandResult CharacterSelectionRestore() =>
|
||||||
|
Invoke((commands, generation) =>
|
||||||
|
commands.CharacterSelection.Restore(generation));
|
||||||
|
|
||||||
|
public RuntimeCommandResult CharacterSelectionCancel() =>
|
||||||
|
Invoke((commands, generation) =>
|
||||||
|
commands.CharacterSelection.Cancel(generation));
|
||||||
|
|
||||||
// ── Campaign FA slice FA4: fellowship page commands ─────────────────
|
// ── Campaign FA slice FA4: fellowship page commands ─────────────────
|
||||||
// Same "capture view+commands under one generation" shape as every
|
// Same "capture view+commands under one generation" shape as every
|
||||||
// method above — a displaced session (reconnect mid-click) can never
|
// method above — a displaced session (reconnect mid-click) can never
|
||||||
|
|
|
||||||
578
src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
Normal file
578
src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
Normal file
|
|
@ -0,0 +1,578 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects Runtime's one borrowed pre-world character-selection owner through
|
||||||
|
/// retail <c>gmCharacterManagementUI</c>'s authored retained layout. The list is
|
||||||
|
/// intentionally flat: the retail class owns no viewport or model preview.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CharacterManagementUiController : IDisposable
|
||||||
|
{
|
||||||
|
internal const uint RootEnum = 0x10000005u;
|
||||||
|
internal const uint RootElementId = 0x1000039Au;
|
||||||
|
internal const uint ListElementId = 0x1000039Du;
|
||||||
|
internal const uint CreateElementId = 0x100003A0u;
|
||||||
|
internal const uint EnterElementId = 0x100003A2u;
|
||||||
|
internal const uint DeleteElementId = 0x1000039Fu;
|
||||||
|
internal const uint RestoreElementId = 0x1000039Eu;
|
||||||
|
|
||||||
|
internal sealed record DialogStrings(
|
||||||
|
Func<string, string> DeleteConfirmation,
|
||||||
|
string DeleteResponse,
|
||||||
|
string PleaseWait,
|
||||||
|
string EnteringWorld);
|
||||||
|
|
||||||
|
private readonly UiRoot _host;
|
||||||
|
private readonly ImportedLayout _layout;
|
||||||
|
private readonly UiTemplateListBox _list;
|
||||||
|
private readonly UiButton _create;
|
||||||
|
private readonly UiButton _enter;
|
||||||
|
private readonly UiButton _delete;
|
||||||
|
private readonly UiButton _restore;
|
||||||
|
private readonly RetailDialogFactory _dialogs;
|
||||||
|
private readonly CharacterSelectionRuntimeBindings _bindings;
|
||||||
|
private readonly DialogStrings _strings;
|
||||||
|
private readonly List<UiButton> _rows = [];
|
||||||
|
private readonly Dictionary<UiButton, uint> _rowIds = [];
|
||||||
|
|
||||||
|
private RuntimeGenerationToken _lastGeneration;
|
||||||
|
private long _lastRevision = long.MinValue;
|
||||||
|
private uint _deleteDialogContext;
|
||||||
|
private uint _operationWaitContext;
|
||||||
|
private uint _enterWaitContext;
|
||||||
|
private uint _errorDialogContext;
|
||||||
|
private bool _active;
|
||||||
|
private bool _suppressDialogCallbacks;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
private CharacterManagementUiController(
|
||||||
|
UiRoot host,
|
||||||
|
ImportedLayout layout,
|
||||||
|
UiTemplateListBox list,
|
||||||
|
UiButton create,
|
||||||
|
UiButton enter,
|
||||||
|
UiButton delete,
|
||||||
|
UiButton restore,
|
||||||
|
RetailDialogFactory dialogs,
|
||||||
|
CharacterSelectionRuntimeBindings bindings,
|
||||||
|
DialogStrings strings)
|
||||||
|
{
|
||||||
|
_host = host;
|
||||||
|
_layout = layout;
|
||||||
|
_list = list;
|
||||||
|
_create = create;
|
||||||
|
_enter = enter;
|
||||||
|
_delete = delete;
|
||||||
|
_restore = restore;
|
||||||
|
_dialogs = dialogs;
|
||||||
|
_bindings = bindings;
|
||||||
|
_strings = strings;
|
||||||
|
|
||||||
|
Root.Left = 0f;
|
||||||
|
Root.Top = 0f;
|
||||||
|
Root.Anchors = AnchorEdges.Left | AnchorEdges.Top
|
||||||
|
| AnchorEdges.Right | AnchorEdges.Bottom;
|
||||||
|
if (host.Width > 0f)
|
||||||
|
Root.Width = host.Width;
|
||||||
|
if (host.Height > 0f)
|
||||||
|
Root.Height = host.Height;
|
||||||
|
Root.ClickThrough = false;
|
||||||
|
Root.Visible = false;
|
||||||
|
|
||||||
|
// Create Character belongs to a future campaign. Keep retail's
|
||||||
|
// authored control in place and visibly ghosted; do not hide it or
|
||||||
|
// invent an action.
|
||||||
|
_create.Visible = true;
|
||||||
|
_create.Enabled = false;
|
||||||
|
_create.OnClick = null;
|
||||||
|
_enter.OnClick = EnterSelected;
|
||||||
|
_delete.OnClick = RequestDelete;
|
||||||
|
_restore.OnClick = RestoreSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal UiElement Root => _layout.Root;
|
||||||
|
internal IReadOnlyList<UiButton> Rows => _rows;
|
||||||
|
internal uint DeleteDialogContext => _deleteDialogContext;
|
||||||
|
internal uint OperationWaitContext => _operationWaitContext;
|
||||||
|
internal uint EnterWaitContext => _enterWaitContext;
|
||||||
|
internal uint ErrorDialogContext => _errorDialogContext;
|
||||||
|
|
||||||
|
internal void ResetSession()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
Deactivate();
|
||||||
|
_lastRevision = long.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static CharacterManagementUiController? Bind(
|
||||||
|
UiRoot host,
|
||||||
|
ImportedLayout layout,
|
||||||
|
Func<uint, uint, UiElement?> templateResolver,
|
||||||
|
RetailDialogFactory dialogs,
|
||||||
|
CharacterSelectionRuntimeBindings bindings,
|
||||||
|
DialogStrings strings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
ArgumentNullException.ThrowIfNull(layout);
|
||||||
|
ArgumentNullException.ThrowIfNull(templateResolver);
|
||||||
|
ArgumentNullException.ThrowIfNull(dialogs);
|
||||||
|
ArgumentNullException.ThrowIfNull(bindings);
|
||||||
|
ArgumentNullException.ThrowIfNull(strings);
|
||||||
|
|
||||||
|
if (ContainsViewport(layout.Root))
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
"[UI] character management: refusing an unapproved model-preview viewport.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout.Root.DatElementId != RootElementId
|
||||||
|
|| layout.FindElement(ListElementId) is not UiTemplateListBox list
|
||||||
|
|| layout.FindElement(CreateElementId) is not UiButton create
|
||||||
|
|| layout.FindElement(EnterElementId) is not UiButton enter
|
||||||
|
|| layout.FindElement(DeleteElementId) is not UiButton delete
|
||||||
|
|| layout.FindElement(RestoreElementId) is not UiButton restore)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
"[UI] character management: the authored root/list/button contract is incomplete.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.TemplateResolver = templateResolver;
|
||||||
|
var controller = new CharacterManagementUiController(
|
||||||
|
host,
|
||||||
|
layout,
|
||||||
|
list,
|
||||||
|
create,
|
||||||
|
enter,
|
||||||
|
delete,
|
||||||
|
restore,
|
||||||
|
dialogs,
|
||||||
|
bindings,
|
||||||
|
strings);
|
||||||
|
host.AddChild(controller.Root);
|
||||||
|
controller.Tick();
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsViewport(UiElement element)
|
||||||
|
{
|
||||||
|
if (element is UiViewport)
|
||||||
|
return true;
|
||||||
|
foreach (UiElement child in element.Children)
|
||||||
|
if (ContainsViewport(child))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Tick()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IRuntimeCharacterSelectionView? view = _bindings.View();
|
||||||
|
RuntimeCharacterSelectionSnapshot snapshot = view?.Snapshot ?? default;
|
||||||
|
if (view is null || !snapshot.IsActive)
|
||||||
|
{
|
||||||
|
Deactivate();
|
||||||
|
_lastGeneration = snapshot.Generation;
|
||||||
|
_lastRevision = snapshot.Revision;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_active)
|
||||||
|
{
|
||||||
|
_active = true;
|
||||||
|
Root.Visible = true;
|
||||||
|
_host.BringToFront(Root);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_lastGeneration != snapshot.Generation
|
||||||
|
|| _lastRevision != snapshot.Revision)
|
||||||
|
{
|
||||||
|
if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster))
|
||||||
|
{
|
||||||
|
bool rowsReady;
|
||||||
|
if (RowsMatchRoster(roster))
|
||||||
|
{
|
||||||
|
ApplyHighlight(snapshot.HighlightedCharacterId);
|
||||||
|
rowsReady = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rowsReady = RebuildRows(
|
||||||
|
roster,
|
||||||
|
snapshot.HighlightedCharacterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowsReady)
|
||||||
|
{
|
||||||
|
_lastGeneration = snapshot.Generation;
|
||||||
|
_lastRevision = snapshot.Revision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// A receive-thread roster/reset raced the borrowed snapshot.
|
||||||
|
// Leave the revision unconsumed so the next frame retries from
|
||||||
|
// one coherent view; never present a partially mixed roster.
|
||||||
|
_lastRevision = long.MinValue;
|
||||||
|
snapshot = view.Snapshot;
|
||||||
|
if (!snapshot.IsActive)
|
||||||
|
{
|
||||||
|
Deactivate();
|
||||||
|
_lastGeneration = snapshot.Generation;
|
||||||
|
_lastRevision = snapshot.Revision;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyHighlight(snapshot.HighlightedCharacterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyButtons(snapshot.Buttons);
|
||||||
|
ReconcileDialogs(view, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_disposed = true;
|
||||||
|
CloseAllDialogs(suppressCallbacks: true);
|
||||||
|
_enter.OnClick = null;
|
||||||
|
_delete.OnClick = null;
|
||||||
|
_restore.OnClick = null;
|
||||||
|
foreach (UiButton row in _rows)
|
||||||
|
{
|
||||||
|
row.OnClick = null;
|
||||||
|
row.OnDoubleClick = null;
|
||||||
|
}
|
||||||
|
_rows.Clear();
|
||||||
|
_rowIds.Clear();
|
||||||
|
_list.Flush();
|
||||||
|
_list.TemplateResolver = null;
|
||||||
|
_host.RemoveChild(Root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCaptureRoster(
|
||||||
|
IRuntimeCharacterSelectionView view,
|
||||||
|
RuntimeCharacterSelectionSnapshot expected,
|
||||||
|
out RuntimeCharacterSelectionEntry[] roster)
|
||||||
|
{
|
||||||
|
roster = new RuntimeCharacterSelectionEntry[expected.RosterCount];
|
||||||
|
for (int i = 0; i < roster.Length; i++)
|
||||||
|
{
|
||||||
|
if (!view.TryGetAt(i, out roster[i]))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RuntimeCharacterSelectionSnapshot after = view.Snapshot;
|
||||||
|
return after.Generation == expected.Generation
|
||||||
|
&& after.Revision == expected.Revision
|
||||||
|
&& after.RosterCount == expected.RosterCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool RowsMatchRoster(
|
||||||
|
IReadOnlyList<RuntimeCharacterSelectionEntry> roster)
|
||||||
|
{
|
||||||
|
if (_rows.Count != roster.Count)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (int i = 0; i < roster.Count; i++)
|
||||||
|
{
|
||||||
|
UiButton row = _rows[i];
|
||||||
|
RuntimeCharacterSelectionEntry character = roster[i];
|
||||||
|
if (!_rowIds.TryGetValue(row, out uint characterId)
|
||||||
|
|| characterId != character.CharacterId
|
||||||
|
|| !string.Equals(row.Label, character.Name, StringComparison.Ordinal)
|
||||||
|
|| row.LabelColor != (character.IsPendingDelete
|
||||||
|
? new Vector4(1f, 0f, 0f, 1f)
|
||||||
|
: Vector4.One))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool RebuildRows(
|
||||||
|
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
|
||||||
|
uint highlightedCharacterId)
|
||||||
|
{
|
||||||
|
foreach (UiButton row in _rows)
|
||||||
|
{
|
||||||
|
row.OnClick = null;
|
||||||
|
row.OnDoubleClick = null;
|
||||||
|
}
|
||||||
|
_rows.Clear();
|
||||||
|
_rowIds.Clear();
|
||||||
|
_list.Flush();
|
||||||
|
|
||||||
|
bool complete = true;
|
||||||
|
foreach (RuntimeCharacterSelectionEntry character in roster)
|
||||||
|
{
|
||||||
|
if (_list.AddItemFromTemplateList(0) is not UiButton row)
|
||||||
|
{
|
||||||
|
complete = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint characterId = character.CharacterId;
|
||||||
|
row.Label = character.Name;
|
||||||
|
row.LabelColor = character.IsPendingDelete
|
||||||
|
? new Vector4(1f, 0f, 0f, 1f)
|
||||||
|
: Vector4.One;
|
||||||
|
row.Enabled = true;
|
||||||
|
row.SuppressSelfToggle = true;
|
||||||
|
row.Selected = characterId == highlightedCharacterId;
|
||||||
|
row.OnClick = () => Highlight(characterId);
|
||||||
|
row.OnDoubleClick = EnterSelected;
|
||||||
|
_rows.Add(row);
|
||||||
|
_rowIds.Add(row, characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complete)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
foreach (UiButton row in _rows)
|
||||||
|
{
|
||||||
|
row.OnClick = null;
|
||||||
|
row.OnDoubleClick = null;
|
||||||
|
}
|
||||||
|
_rows.Clear();
|
||||||
|
_rowIds.Clear();
|
||||||
|
_list.Flush();
|
||||||
|
_lastRevision = long.MinValue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyHighlight(uint highlightedCharacterId)
|
||||||
|
{
|
||||||
|
foreach (UiButton row in _rows)
|
||||||
|
row.Selected = _rowIds.TryGetValue(row, out uint characterId)
|
||||||
|
&& characterId == highlightedCharacterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyButtons(RuntimeCharacterSelectionButtons buttons)
|
||||||
|
{
|
||||||
|
_create.Visible = true;
|
||||||
|
_create.Enabled = false;
|
||||||
|
_enter.Enabled = buttons.CanEnter;
|
||||||
|
_delete.Visible = buttons.DeleteVisible;
|
||||||
|
_delete.Enabled = buttons.CanDelete;
|
||||||
|
_restore.Visible = buttons.RestoreVisible;
|
||||||
|
_restore.Enabled = buttons.CanRestore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Highlight(uint characterId)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_bindings.Highlight(characterId);
|
||||||
|
InvalidateAndTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnterSelected()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Open retail's wait context before the synchronous Runtime command
|
||||||
|
// starts its existing ServerReady transaction. The state projection
|
||||||
|
// remains authoritative and closes it on InWorld/error/reset.
|
||||||
|
EnsureEnterWait();
|
||||||
|
RuntimeCommandResult result = _bindings.Enter();
|
||||||
|
if (!result.Accepted)
|
||||||
|
CloseContext(ref _enterWaitContext, suppressCallback: true);
|
||||||
|
InvalidateAndTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RequestDelete()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_bindings.RequestDelete();
|
||||||
|
InvalidateAndTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreSelected()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
RuntimeCommandResult result = _bindings.Restore();
|
||||||
|
if (result.Accepted)
|
||||||
|
EnsureOperationWait();
|
||||||
|
InvalidateAndTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReconcileDialogs(
|
||||||
|
IRuntimeCharacterSelectionView view,
|
||||||
|
RuntimeCharacterSelectionSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (snapshot.Error is { } error)
|
||||||
|
{
|
||||||
|
CloseContext(ref _deleteDialogContext, suppressCallback: true);
|
||||||
|
CloseContext(ref _operationWaitContext, suppressCallback: true);
|
||||||
|
CloseContext(ref _enterWaitContext, suppressCallback: true);
|
||||||
|
EnsureError(error.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseContext(ref _errorDialogContext, suppressCallback: true);
|
||||||
|
if (snapshot.Lifecycle == RuntimeCharacterSelectionLifecycle.EnteringWorld)
|
||||||
|
{
|
||||||
|
CloseContext(ref _deleteDialogContext, suppressCallback: true);
|
||||||
|
CloseContext(ref _operationWaitContext, suppressCallback: true);
|
||||||
|
EnsureEnterWait();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseContext(ref _enterWaitContext, suppressCallback: true);
|
||||||
|
if (snapshot.PendingDeleteCharacterId != 0u
|
||||||
|
&& view.TryGet(snapshot.PendingDeleteCharacterId, out RuntimeCharacterSelectionEntry pending))
|
||||||
|
{
|
||||||
|
EnsureDeleteConfirmation(pending.Name);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CloseContext(ref _deleteDialogContext, suppressCallback: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
|
||||||
|
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
||||||
|
or RuntimeCharacterSelectionOperation.RestoreRequested)
|
||||||
|
{
|
||||||
|
EnsureOperationWait();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CloseContext(ref _operationWaitContext, suppressCallback: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureDeleteConfirmation(string characterName)
|
||||||
|
{
|
||||||
|
if (_deleteDialogContext != 0u)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_deleteDialogContext = _dialogs.MakeConfirmationTextInput(
|
||||||
|
_strings.DeleteConfirmation(characterName),
|
||||||
|
data =>
|
||||||
|
{
|
||||||
|
_deleteDialogContext = 0u;
|
||||||
|
if (_disposed || _suppressDialogCallbacks)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string response = data.GetString(
|
||||||
|
RetailDialogProperty.TextInputResult) ?? string.Empty;
|
||||||
|
if (string.Equals(
|
||||||
|
response,
|
||||||
|
_strings.DeleteResponse,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_bindings.ConfirmDelete();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_bindings.Cancel();
|
||||||
|
}
|
||||||
|
InvalidateAndTick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureOperationWait()
|
||||||
|
{
|
||||||
|
if (_operationWaitContext == 0u)
|
||||||
|
_operationWaitContext = _dialogs.MakeWait(_strings.PleaseWait);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureEnterWait()
|
||||||
|
{
|
||||||
|
if (_enterWaitContext == 0u)
|
||||||
|
_enterWaitContext = _dialogs.MakeWait(_strings.EnteringWorld);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureError(string message)
|
||||||
|
{
|
||||||
|
if (_errorDialogContext != 0u)
|
||||||
|
return;
|
||||||
|
_errorDialogContext = _dialogs.MakeMessage(
|
||||||
|
message,
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
_errorDialogContext = 0u;
|
||||||
|
if (_disposed || _suppressDialogCallbacks)
|
||||||
|
return;
|
||||||
|
_bindings.Cancel();
|
||||||
|
InvalidateAndTick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InvalidateAndTick()
|
||||||
|
{
|
||||||
|
_lastRevision = long.MinValue;
|
||||||
|
Tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Deactivate()
|
||||||
|
{
|
||||||
|
if (_active)
|
||||||
|
{
|
||||||
|
_active = false;
|
||||||
|
Root.Visible = false;
|
||||||
|
}
|
||||||
|
foreach (UiButton row in _rows)
|
||||||
|
{
|
||||||
|
row.OnClick = null;
|
||||||
|
row.OnDoubleClick = null;
|
||||||
|
}
|
||||||
|
_rows.Clear();
|
||||||
|
_rowIds.Clear();
|
||||||
|
_list.Flush();
|
||||||
|
CloseAllDialogs(suppressCallbacks: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseAllDialogs(bool suppressCallbacks)
|
||||||
|
{
|
||||||
|
bool previous = _suppressDialogCallbacks;
|
||||||
|
_suppressDialogCallbacks |= suppressCallbacks;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CloseContext(ref _deleteDialogContext, suppressCallback: false);
|
||||||
|
CloseContext(ref _operationWaitContext, suppressCallback: false);
|
||||||
|
CloseContext(ref _enterWaitContext, suppressCallback: false);
|
||||||
|
CloseContext(ref _errorDialogContext, suppressCallback: false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressDialogCallbacks = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseContext(ref uint context, bool suppressCallback)
|
||||||
|
{
|
||||||
|
uint closing = context;
|
||||||
|
if (closing == 0u)
|
||||||
|
return;
|
||||||
|
context = 0u;
|
||||||
|
|
||||||
|
bool previous = _suppressDialogCallbacks;
|
||||||
|
_suppressDialogCallbacks |= suppressCallback;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_dialogs.CloseDialog(closing);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressDialogCallbacks = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -122,6 +122,8 @@ public static class DatWidgetFactory
|
||||||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
||||||
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
|
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
|
||||||
0x13 => new UiDialogRoot(), // ConfirmationDialog
|
0x13 => new UiDialogRoot(), // ConfirmationDialog
|
||||||
|
0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog
|
||||||
|
0x17 => new UiDialogRoot(), // MessageDialog
|
||||||
0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396)
|
0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396)
|
||||||
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
||||||
0x10000035u => BuildCheckbox(
|
0x10000035u => BuildCheckbox(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail type-5 <c>ConfirmationTextInputDialog</c> (class type
|
||||||
|
/// <c>0x15</c>, catalog root <c>0x2C</c>). Accept stores the field text under
|
||||||
|
/// property <c>0x9C</c>; reject/Escape stores the empty string. Character
|
||||||
|
/// deletion is the first consumer and performs retail's case-insensitive
|
||||||
|
/// comparison with the localized <c>DELETE</c> response in its callback.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RetailConfirmationTextInputDialogView : IRetailDialogView
|
||||||
|
{
|
||||||
|
public const uint RootElementId = 0x2Cu;
|
||||||
|
public const uint InputElementId = 0x2Cu;
|
||||||
|
public const uint AcceptButtonId = 0x2Eu;
|
||||||
|
public const uint RejectButtonId = 0x2Fu;
|
||||||
|
public const uint PopupElementId = 0x3Du;
|
||||||
|
public const uint MessageElementId = 0x3Eu;
|
||||||
|
|
||||||
|
private readonly UiRoot _host;
|
||||||
|
private readonly RetailDialogData _data;
|
||||||
|
private readonly uint _context;
|
||||||
|
private readonly Action<uint> _closeDialog;
|
||||||
|
private readonly UiElement _popup;
|
||||||
|
private readonly UiText _message;
|
||||||
|
private readonly UiField _input;
|
||||||
|
private readonly UiButton _accept;
|
||||||
|
private readonly UiButton _reject;
|
||||||
|
private readonly float _basePopupHeight;
|
||||||
|
private readonly float _baseMessageHeight;
|
||||||
|
private bool _focusPending = true;
|
||||||
|
|
||||||
|
public RetailConfirmationTextInputDialogView(
|
||||||
|
UiRoot host,
|
||||||
|
ImportedLayout layout,
|
||||||
|
RetailDialogData data,
|
||||||
|
uint context,
|
||||||
|
Action<uint> closeDialog)
|
||||||
|
{
|
||||||
|
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
|
ArgumentNullException.ThrowIfNull(layout);
|
||||||
|
_data = data ?? throw new ArgumentNullException(nameof(data));
|
||||||
|
_context = context;
|
||||||
|
_closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog));
|
||||||
|
|
||||||
|
Root = layout.Root as UiDialogRoot
|
||||||
|
?? throw new ArgumentException(
|
||||||
|
"Confirmation-text-input layout root is not a UiDialogRoot.",
|
||||||
|
nameof(layout));
|
||||||
|
_popup = layout.FindElement(PopupElementId)
|
||||||
|
?? throw new ArgumentException(
|
||||||
|
"Confirmation-text-input layout is missing popup element 0x3D.",
|
||||||
|
nameof(layout));
|
||||||
|
_message = layout.FindElement(MessageElementId) as UiText
|
||||||
|
?? throw new ArgumentException(
|
||||||
|
"Confirmation-text-input layout is missing text element 0x3E.",
|
||||||
|
nameof(layout));
|
||||||
|
// The field deliberately repeats the root's numeric id. ImportedLayout
|
||||||
|
// registers descendants after ancestors, matching GetChildRecursive's
|
||||||
|
// effective result for this catalog shape.
|
||||||
|
_input = layout.FindElement(InputElementId) as UiField
|
||||||
|
?? throw new ArgumentException(
|
||||||
|
"Confirmation-text-input layout is missing input field 0x2C.",
|
||||||
|
nameof(layout));
|
||||||
|
_accept = layout.FindElement(AcceptButtonId) as UiButton
|
||||||
|
?? throw new ArgumentException(
|
||||||
|
"Confirmation-text-input layout is missing accept button 0x2E.",
|
||||||
|
nameof(layout));
|
||||||
|
_reject = layout.FindElement(RejectButtonId) as UiButton
|
||||||
|
?? throw new ArgumentException(
|
||||||
|
"Confirmation-text-input layout is missing reject button 0x2F.",
|
||||||
|
nameof(layout));
|
||||||
|
|
||||||
|
_basePopupHeight = _popup.Height;
|
||||||
|
_baseMessageHeight = _message.Height;
|
||||||
|
_popup.LayoutPolicy = null;
|
||||||
|
_popup.Anchors = AnchorEdges.None;
|
||||||
|
_message.LayoutPolicy = null;
|
||||||
|
_message.Anchors = AnchorEdges.None;
|
||||||
|
_message.Padding = 0f;
|
||||||
|
_message.Selectable = false;
|
||||||
|
_input.ClearOnSubmit = false;
|
||||||
|
_input.RecordHistory = false;
|
||||||
|
|
||||||
|
if (_data.GetString(RetailDialogProperty.TextInputAcceptLabel) is { } acceptLabel)
|
||||||
|
_accept.Label = acceptLabel;
|
||||||
|
if (_data.GetString(RetailDialogProperty.TextInputRejectLabel) is { } rejectLabel)
|
||||||
|
_reject.Label = rejectLabel;
|
||||||
|
|
||||||
|
Root.Cancel = Reject;
|
||||||
|
_accept.OnClick = Accept;
|
||||||
|
_reject.OnClick = Reject;
|
||||||
|
_input.OnSubmit = _ => Accept();
|
||||||
|
SetMessage(_data.GetString(RetailDialogProperty.Message) ?? string.Empty);
|
||||||
|
SizeAndCenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UiDialogRoot Root { get; }
|
||||||
|
|
||||||
|
public void Tick()
|
||||||
|
{
|
||||||
|
SizeAndCenter();
|
||||||
|
if (_focusPending && Root.Parent is not null)
|
||||||
|
{
|
||||||
|
_host.SetKeyboardFocus(_input);
|
||||||
|
_focusPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetPendingCount(int count)
|
||||||
|
{
|
||||||
|
// This catalog root authors no pending-count display.
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DetachHandlers()
|
||||||
|
{
|
||||||
|
Root.Cancel = null;
|
||||||
|
_accept.OnClick = null;
|
||||||
|
_reject.OnClick = null;
|
||||||
|
_input.OnSubmit = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Accept()
|
||||||
|
{
|
||||||
|
_data.Set(RetailDialogProperty.TextInputResult, _input.Text);
|
||||||
|
_closeDialog(_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reject()
|
||||||
|
{
|
||||||
|
_data.Set(RetailDialogProperty.TextInputResult, string.Empty);
|
||||||
|
_closeDialog(_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetMessage(string text)
|
||||||
|
{
|
||||||
|
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
|
||||||
|
Func<string, float> measure = _message.DatFont is { } font
|
||||||
|
? font.MeasureWidth
|
||||||
|
: static value => value.Length * 8f;
|
||||||
|
IReadOnlyList<string> wrapped = UiText.WrapWords(text, measure, maximumWidth);
|
||||||
|
var lines = new UiText.Line[wrapped.Count];
|
||||||
|
for (int i = 0; i < wrapped.Count; i++)
|
||||||
|
lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
|
||||||
|
_message.LinesProvider = () => lines;
|
||||||
|
|
||||||
|
float lineHeight = _message.DatFont?.LineHeight ?? 16f;
|
||||||
|
_message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
|
||||||
|
_popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SizeAndCenter()
|
||||||
|
{
|
||||||
|
Root.Left = 0f;
|
||||||
|
Root.Top = 0f;
|
||||||
|
Root.Width = _host.Width;
|
||||||
|
Root.Height = _host.Height;
|
||||||
|
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
|
||||||
|
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,9 @@ public static class RetailDialogProperty
|
||||||
public const uint AcceptLabel = 0x90u;
|
public const uint AcceptLabel = 0x90u;
|
||||||
public const uint RejectLabel = 0x91u;
|
public const uint RejectLabel = 0x91u;
|
||||||
public const uint ConfirmationResult = 0x92u;
|
public const uint ConfirmationResult = 0x92u;
|
||||||
|
public const uint TextInputAcceptLabel = 0x9Au;
|
||||||
|
public const uint TextInputRejectLabel = 0x9Bu;
|
||||||
|
public const uint TextInputResult = 0x9Cu;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
|
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
|
||||||
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable.
|
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable.
|
||||||
|
|
@ -123,4 +126,21 @@ public sealed class RetailDialogData
|
||||||
.Set(RetailDialogProperty.ElementAttribute40, true)
|
.Set(RetailDialogProperty.ElementAttribute40, true)
|
||||||
.Set(RetailDialogProperty.Message, message);
|
.Set(RetailDialogProperty.Message, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static RetailDialogData Message(string message)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(message);
|
||||||
|
return new RetailDialogData()
|
||||||
|
.Set(RetailDialogProperty.Type, RetailDialogType.Message)
|
||||||
|
.Set(RetailDialogProperty.Message, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RetailDialogData ConfirmationTextInput(string message)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(message);
|
||||||
|
return new RetailDialogData()
|
||||||
|
.Set(RetailDialogProperty.Type, RetailDialogType.ConfirmationTextInput)
|
||||||
|
.Set(RetailDialogProperty.ElementAttribute40, true)
|
||||||
|
.Set(RetailDialogProperty.Message, message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,26 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
return MakeDialog(data, callback: null);
|
return MakeDialog(data, callback: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public uint MakeMessage(
|
||||||
|
string message,
|
||||||
|
Action<RetailDialogData>? callback = null,
|
||||||
|
uint queueKey = DefaultQueueKey)
|
||||||
|
{
|
||||||
|
RetailDialogData data = RetailDialogData.Message(message)
|
||||||
|
.Set(RetailDialogProperty.QueueKey, queueKey);
|
||||||
|
return MakeDialog(data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public uint MakeConfirmationTextInput(
|
||||||
|
string message,
|
||||||
|
Action<RetailDialogData>? callback = null,
|
||||||
|
uint queueKey = DefaultQueueKey)
|
||||||
|
{
|
||||||
|
RetailDialogData data = RetailDialogData.ConfirmationTextInput(message)
|
||||||
|
.Set(RetailDialogProperty.QueueKey, queueKey);
|
||||||
|
return MakeDialog(data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active
|
/// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active
|
||||||
/// nonqueued dialog, an active queued dialog, or an item still pending in a queue.
|
/// nonqueued dialog, an active queued dialog, or an item still pending in a queue.
|
||||||
|
|
@ -275,7 +295,10 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
private void CreateDialog(DialogInfo info)
|
private void CreateDialog(DialogInfo info)
|
||||||
{
|
{
|
||||||
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
|
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
|
||||||
if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait))
|
if (type is not (RetailDialogType.Confirmation
|
||||||
|
or RetailDialogType.Wait
|
||||||
|
or RetailDialogType.Message
|
||||||
|
or RetailDialogType.ConfirmationTextInput))
|
||||||
throw new NotSupportedException(
|
throw new NotSupportedException(
|
||||||
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
|
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
|
||||||
|
|
||||||
|
|
@ -285,6 +308,13 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
IRetailDialogView view = type switch
|
IRetailDialogView view = type switch
|
||||||
{
|
{
|
||||||
RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data),
|
RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data),
|
||||||
|
RetailDialogType.Message => new RetailMessageDialogView(
|
||||||
|
_host, layout, info.Data, info.Context,
|
||||||
|
context => CloseDialog(context)),
|
||||||
|
RetailDialogType.ConfirmationTextInput =>
|
||||||
|
new RetailConfirmationTextInputDialogView(
|
||||||
|
_host, layout, info.Data, info.Context,
|
||||||
|
context => CloseDialog(context)),
|
||||||
_ => new RetailConfirmationDialogView(
|
_ => new RetailConfirmationDialogView(
|
||||||
_host, layout, info.Data, info.Context,
|
_host, layout, info.Data, info.Context,
|
||||||
context => CloseDialog(context)),
|
context => CloseDialog(context)),
|
||||||
|
|
@ -294,6 +324,7 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
_host.BringToFront(view.Root);
|
_host.BringToFront(view.Root);
|
||||||
_openOrder.Add(info);
|
_openOrder.Add(info);
|
||||||
_host.Modal = view.Root;
|
_host.Modal = view.Root;
|
||||||
|
view.Tick();
|
||||||
UpdatePendingDialogDisplays();
|
UpdatePendingDialogDisplays();
|
||||||
DialogOpened?.Invoke(info.Context);
|
DialogOpened?.Invoke(info.Context);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
src/AcDream.App/UI/Layout/RetailMessageDialogView.cs
Normal file
105
src/AcDream.App/UI/Layout/RetailMessageDialogView.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail type-3 <c>MessageDialog</c> (class type <c>0x17</c>, catalog root
|
||||||
|
/// <c>0x24</c>). It shares the dialog catalog's popup/message pair with the
|
||||||
|
/// existing confirmation and wait presenters and closes from its authored OK
|
||||||
|
/// button <c>0x26</c> or Escape.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RetailMessageDialogView : IRetailDialogView
|
||||||
|
{
|
||||||
|
public const uint RootElementId = 0x24u;
|
||||||
|
public const uint OkButtonId = 0x26u;
|
||||||
|
public const uint PopupElementId = 0x3Du;
|
||||||
|
public const uint MessageElementId = 0x3Eu;
|
||||||
|
|
||||||
|
private readonly UiRoot _host;
|
||||||
|
private readonly uint _context;
|
||||||
|
private readonly Action<uint> _closeDialog;
|
||||||
|
private readonly UiElement _popup;
|
||||||
|
private readonly UiText _message;
|
||||||
|
private readonly UiButton _ok;
|
||||||
|
private readonly float _basePopupHeight;
|
||||||
|
private readonly float _baseMessageHeight;
|
||||||
|
|
||||||
|
public RetailMessageDialogView(
|
||||||
|
UiRoot host,
|
||||||
|
ImportedLayout layout,
|
||||||
|
RetailDialogData data,
|
||||||
|
uint context,
|
||||||
|
Action<uint> closeDialog)
|
||||||
|
{
|
||||||
|
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
|
ArgumentNullException.ThrowIfNull(layout);
|
||||||
|
ArgumentNullException.ThrowIfNull(data);
|
||||||
|
_context = context;
|
||||||
|
_closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog));
|
||||||
|
|
||||||
|
Root = layout.Root as UiDialogRoot
|
||||||
|
?? throw new ArgumentException("Message layout root is not a UiDialogRoot.", nameof(layout));
|
||||||
|
_popup = layout.FindElement(PopupElementId)
|
||||||
|
?? throw new ArgumentException("Message layout is missing popup element 0x3D.", nameof(layout));
|
||||||
|
_message = layout.FindElement(MessageElementId) as UiText
|
||||||
|
?? throw new ArgumentException("Message layout is missing text element 0x3E.", nameof(layout));
|
||||||
|
_ok = layout.FindElement(OkButtonId) as UiButton
|
||||||
|
?? throw new ArgumentException("Message layout is missing OK button 0x26.", nameof(layout));
|
||||||
|
|
||||||
|
_basePopupHeight = _popup.Height;
|
||||||
|
_baseMessageHeight = _message.Height;
|
||||||
|
_popup.LayoutPolicy = null;
|
||||||
|
_popup.Anchors = AnchorEdges.None;
|
||||||
|
_message.LayoutPolicy = null;
|
||||||
|
_message.Anchors = AnchorEdges.None;
|
||||||
|
_message.Padding = 0f;
|
||||||
|
_message.Selectable = false;
|
||||||
|
|
||||||
|
Root.Cancel = Close;
|
||||||
|
_ok.OnClick = Close;
|
||||||
|
SetMessage(data.GetString(RetailDialogProperty.Message) ?? string.Empty);
|
||||||
|
SizeAndCenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UiDialogRoot Root { get; }
|
||||||
|
|
||||||
|
public void Tick() => SizeAndCenter();
|
||||||
|
|
||||||
|
public void SetPendingCount(int count)
|
||||||
|
{
|
||||||
|
// MessageDialog has no pending-count subtree in the retail catalog.
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DetachHandlers()
|
||||||
|
{
|
||||||
|
Root.Cancel = null;
|
||||||
|
_ok.OnClick = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Close() => _closeDialog(_context);
|
||||||
|
|
||||||
|
private void SetMessage(string text)
|
||||||
|
{
|
||||||
|
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
|
||||||
|
Func<string, float> measure = _message.DatFont is { } font
|
||||||
|
? font.MeasureWidth
|
||||||
|
: static value => value.Length * 8f;
|
||||||
|
IReadOnlyList<string> wrapped = UiText.WrapWords(text, measure, maximumWidth);
|
||||||
|
var lines = new UiText.Line[wrapped.Count];
|
||||||
|
for (int i = 0; i < wrapped.Count; i++)
|
||||||
|
lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
|
||||||
|
_message.LinesProvider = () => lines;
|
||||||
|
|
||||||
|
float lineHeight = _message.DatFont?.LineHeight ?? 16f;
|
||||||
|
_message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
|
||||||
|
_popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SizeAndCenter()
|
||||||
|
{
|
||||||
|
Root.Left = 0f;
|
||||||
|
Root.Top = 0f;
|
||||||
|
Root.Width = _host.Width;
|
||||||
|
Root.Height = _host.Height;
|
||||||
|
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
|
||||||
|
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ using AcDream.Core.Selection;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
using AcDream.Core.Input;
|
using AcDream.Core.Input;
|
||||||
using AcDream.UI.Abstractions;
|
using AcDream.UI.Abstractions;
|
||||||
|
|
@ -369,6 +370,21 @@ public sealed record KeyboardRuntimeBindings(
|
||||||
InputDispatcher? Dispatcher,
|
InputDispatcher? Dispatcher,
|
||||||
string KeyBindingsFilePath);
|
string KeyBindingsFilePath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Borrowed LA7b character-selection projection and its generation-capturing
|
||||||
|
/// typed command routes. App owns no roster, selection, operation, or error
|
||||||
|
/// mirror; an absent view means the current adapter has not bound (or has
|
||||||
|
/// already been released).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CharacterSelectionRuntimeBindings(
|
||||||
|
Func<IRuntimeCharacterSelectionView?> View,
|
||||||
|
Func<uint, RuntimeCommandResult> Highlight,
|
||||||
|
Func<RuntimeCommandResult> Enter,
|
||||||
|
Func<RuntimeCommandResult> RequestDelete,
|
||||||
|
Func<RuntimeCommandResult> ConfirmDelete,
|
||||||
|
Func<RuntimeCommandResult> Restore,
|
||||||
|
Func<RuntimeCommandResult> Cancel);
|
||||||
|
|
||||||
public sealed record RetailUiRuntimeBindings(
|
public sealed record RetailUiRuntimeBindings(
|
||||||
UiHost Host,
|
UiHost Host,
|
||||||
RetailUiAssets Assets,
|
RetailUiAssets Assets,
|
||||||
|
|
@ -395,7 +411,8 @@ public sealed record RetailUiRuntimeBindings(
|
||||||
BufferedUiRegistry? Plugins,
|
BufferedUiRegistry? Plugins,
|
||||||
RetailUiPersistenceBindings? Persistence,
|
RetailUiPersistenceBindings? Persistence,
|
||||||
RetailUiProbeBindings Probe,
|
RetailUiProbeBindings Probe,
|
||||||
KeyboardRuntimeBindings? Keyboard = null);
|
KeyboardRuntimeBindings? Keyboard = null,
|
||||||
|
CharacterSelectionRuntimeBindings? CharacterSelection = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composition owner for the production retained gameplay UI. GameWindow supplies
|
/// Composition owner for the production retained gameplay UI. GameWindow supplies
|
||||||
|
|
@ -483,6 +500,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
MountVendor();
|
MountVendor();
|
||||||
MountSecureTrade();
|
MountSecureTrade();
|
||||||
MountItemCooldowns();
|
MountItemCooldowns();
|
||||||
|
MountCharacterManagement();
|
||||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||||
BindToolbarPanelButtons();
|
BindToolbarPanelButtons();
|
||||||
SyncToolbarWindowButtons();
|
SyncToolbarWindowButtons();
|
||||||
|
|
@ -577,6 +595,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
public VendorUiController? VendorController { get; private set; }
|
public VendorUiController? VendorController { get; private set; }
|
||||||
public OptionsPanelController? OptionsPanelController { get; private set; }
|
public OptionsPanelController? OptionsPanelController { get; private set; }
|
||||||
public SocialPanelController? SocialPanelController { get; private set; }
|
public SocialPanelController? SocialPanelController { get; private set; }
|
||||||
|
internal CharacterManagementUiController? CharacterManagementController { get; private set; }
|
||||||
|
|
||||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||||
{
|
{
|
||||||
|
|
@ -622,6 +641,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
ExternalContainerController?.Tick();
|
ExternalContainerController?.Tick();
|
||||||
SocialPanelController?.Tick();
|
SocialPanelController?.Tick();
|
||||||
_itemCooldownController?.Tick();
|
_itemCooldownController?.Tick();
|
||||||
|
CharacterManagementController?.Tick();
|
||||||
DialogFactory?.Tick();
|
DialogFactory?.Tick();
|
||||||
Host.Tick(deltaSeconds);
|
Host.Tick(deltaSeconds);
|
||||||
_automation?.Tick(deltaSeconds);
|
_automation?.Tick(deltaSeconds);
|
||||||
|
|
@ -788,6 +808,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
CharacterManagementController?.ResetSession();
|
||||||
DialogFactory?.Reset();
|
DialogFactory?.Reset();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
@ -3669,6 +3690,144 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
|
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void MountCharacterManagement()
|
||||||
|
{
|
||||||
|
CharacterSelectionRuntimeBindings? bindings =
|
||||||
|
_bindings.CharacterSelection;
|
||||||
|
if (bindings is null)
|
||||||
|
return;
|
||||||
|
if (DialogFactory is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
"[UI] character management: retail DialogFactory is unavailable.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint stringTableId = 0x23000002u;
|
||||||
|
uint layoutId;
|
||||||
|
ImportedLayout? layout;
|
||||||
|
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||||
|
lock (_bindings.Assets.DatLock)
|
||||||
|
{
|
||||||
|
// gmCharacterManagementUI's framework call passes enum
|
||||||
|
// 0x10000005 and category/table 5, then selects root 0x1000039A.
|
||||||
|
layoutId = RetailDataIdResolver.Resolve(
|
||||||
|
_bindings.Assets.Dats,
|
||||||
|
CharacterManagementUiController.RootEnum,
|
||||||
|
5u);
|
||||||
|
layout = layoutId == 0u
|
||||||
|
? null
|
||||||
|
: LayoutImporter.Import(
|
||||||
|
_bindings.Assets.Dats,
|
||||||
|
layoutId,
|
||||||
|
CharacterManagementUiController.RootElementId,
|
||||||
|
_bindings.Assets.ResolveSprite,
|
||||||
|
_bindings.Assets.DefaultFont,
|
||||||
|
_bindings.Assets.ResolveFont);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
"[UI] character management: enum-table-5 root could not be imported.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? deleteResponse;
|
||||||
|
string? deleteConfirmationProbe;
|
||||||
|
string? pleaseWait;
|
||||||
|
string? enteringWorld;
|
||||||
|
lock (_bindings.Assets.DatLock)
|
||||||
|
{
|
||||||
|
deleteConfirmationProbe = strings.ResolveTemplate(
|
||||||
|
stringTableId,
|
||||||
|
"ID_CharacterManagement_DeleteCharacterConfirmation",
|
||||||
|
new Dictionary<uint, string>
|
||||||
|
{
|
||||||
|
[DatStringResolver.PlayerVariable] = string.Empty,
|
||||||
|
});
|
||||||
|
deleteResponse = ResolveCharacterManagementString(
|
||||||
|
strings,
|
||||||
|
stringTableId,
|
||||||
|
"ID_CharacterManagement_DeleteCharacterResponse");
|
||||||
|
pleaseWait = ResolveCharacterManagementString(
|
||||||
|
strings,
|
||||||
|
stringTableId,
|
||||||
|
"ID_CharacterManagement_PleaseWait");
|
||||||
|
enteringWorld = ResolveCharacterManagementString(
|
||||||
|
strings,
|
||||||
|
stringTableId,
|
||||||
|
"ID_Character_EnteringWorld");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteConfirmationProbe is null
|
||||||
|
|| deleteResponse is null
|
||||||
|
|| pleaseWait is null
|
||||||
|
|| enteringWorld is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
"[UI] character management: required retail strings are unavailable.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
|
||||||
|
{
|
||||||
|
lock (_bindings.Assets.DatLock)
|
||||||
|
{
|
||||||
|
return LayoutImporter.Import(
|
||||||
|
_bindings.Assets.Dats,
|
||||||
|
templateLayoutId,
|
||||||
|
templateElementId,
|
||||||
|
_bindings.Assets.ResolveSprite,
|
||||||
|
_bindings.Assets.DefaultFont,
|
||||||
|
_bindings.Assets.ResolveFont)?.Root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string ComposeDeleteConfirmation(string characterName)
|
||||||
|
{
|
||||||
|
lock (_bindings.Assets.DatLock)
|
||||||
|
{
|
||||||
|
return NormalizeRetailNewlines(strings.ResolveTemplate(
|
||||||
|
stringTableId,
|
||||||
|
"ID_CharacterManagement_DeleteCharacterConfirmation",
|
||||||
|
new Dictionary<uint, string>
|
||||||
|
{
|
||||||
|
[DatStringResolver.PlayerVariable] = characterName,
|
||||||
|
})!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CharacterManagementController = CharacterManagementUiController.Bind(
|
||||||
|
Host.Root,
|
||||||
|
layout,
|
||||||
|
ResolveTemplate,
|
||||||
|
DialogFactory,
|
||||||
|
bindings,
|
||||||
|
new CharacterManagementUiController.DialogStrings(
|
||||||
|
ComposeDeleteConfirmation,
|
||||||
|
deleteResponse,
|
||||||
|
pleaseWait,
|
||||||
|
enteringWorld));
|
||||||
|
|
||||||
|
if (CharacterManagementController is null)
|
||||||
|
return;
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[UI] retail character management from enum table 5 "
|
||||||
|
+ $"(0x10000005 -> 0x{layoutId:X8}, root 0x1000039A; flat list, no viewport).");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveCharacterManagementString(
|
||||||
|
DatStringResolver strings,
|
||||||
|
uint tableId,
|
||||||
|
string key) =>
|
||||||
|
strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value
|
||||||
|
? NormalizeRetailNewlines(value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string NormalizeRetailNewlines(string value) =>
|
||||||
|
value.Replace("\\n", "\n", StringComparison.Ordinal);
|
||||||
|
|
||||||
private void MountItemCooldowns()
|
private void MountItemCooldowns()
|
||||||
{
|
{
|
||||||
ItemCooldownAssets? assets;
|
ItemCooldownAssets? assets;
|
||||||
|
|
@ -3712,7 +3871,11 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
() => _itemConfirmationController?.Dispose(),
|
() => _itemConfirmationController?.Dispose(),
|
||||||
() => _gameplayConfirmationController?.Dispose(),
|
() =>
|
||||||
|
{
|
||||||
|
CharacterManagementController?.Dispose();
|
||||||
|
_gameplayConfirmationController?.Dispose();
|
||||||
|
},
|
||||||
() => DialogFactory?.Dispose(),
|
() => DialogFactory?.Dispose(),
|
||||||
_panelUi.Dispose,
|
_panelUi.Dispose,
|
||||||
Host.Dispose);
|
Host.Dispose);
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
||||||
public Action? OnClick { get; set; }
|
public Action? OnClick { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional left-button double-click handler. Null preserves the existing
|
||||||
|
/// bubbling behavior; character-management row template 0x100003A5 opts in
|
||||||
|
/// for retail's element message 0x1A (activate the selected character).
|
||||||
|
/// </summary>
|
||||||
|
public Action? OnDoubleClick { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
|
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
|
||||||
/// screen: right-click a bound key button to erase that one binding —
|
/// screen: right-click a bound key button to erase that one binding —
|
||||||
|
|
@ -551,6 +558,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
OnClick?.Invoke();
|
OnClick?.Invoke();
|
||||||
OnClickAt?.Invoke(e.Data1, e.Data2);
|
OnClickAt?.Invoke(e.Data1, e.Data2);
|
||||||
return OnClick is not null || OnClickAt is not null;
|
return OnClick is not null || OnClickAt is not null;
|
||||||
|
case UiEventType.DoubleClick:
|
||||||
|
if (OnDoubleClick is null) return false;
|
||||||
|
if (!Enabled) return true;
|
||||||
|
OnDoubleClick.Invoke();
|
||||||
|
return true;
|
||||||
case UiEventType.RightClick:
|
case UiEventType.RightClick:
|
||||||
// S6 (2026-08-11 review): unlike Click (whose swallow-when-
|
// S6 (2026-08-11 review): unlike Click (whose swallow-when-
|
||||||
// disabled is pre-existing, harmless-by-construction behavior
|
// disabled is pre-existing, harmless-by-construction behavior
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ using AcDream.App.World;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.UI.Abstractions;
|
using AcDream.UI.Abstractions;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Composition;
|
namespace AcDream.App.Tests.Composition;
|
||||||
|
|
@ -106,6 +107,44 @@ public sealed class InteractionUiRuntimeSourcesTests
|
||||||
Assert.Throws<ObjectDisposedException>(() => source.Bind(first, first));
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first, first));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharacterSelectionProjectionBorrowsExactAdapterAndEveryRouteBecomesInert()
|
||||||
|
{
|
||||||
|
var source = new DeferredGameRuntimeStateCommands();
|
||||||
|
var target = new RuntimeTarget(new RuntimeGenerationToken(11));
|
||||||
|
|
||||||
|
Assert.Null(source.CharacterSelection);
|
||||||
|
Assert.Equal(RuntimeCommandStatus.Inactive,
|
||||||
|
source.CharacterSelectionEnter().Status);
|
||||||
|
|
||||||
|
IDisposable binding = source.Bind(target, target);
|
||||||
|
Assert.Same(target, source.CharacterSelection);
|
||||||
|
RuntimeCommandResult[] accepted =
|
||||||
|
[
|
||||||
|
source.CharacterSelectionHighlight(0x50000001u),
|
||||||
|
source.CharacterSelectionEnter(),
|
||||||
|
source.CharacterSelectionRequestDelete(),
|
||||||
|
source.CharacterSelectionConfirmDelete(),
|
||||||
|
source.CharacterSelectionRestore(),
|
||||||
|
source.CharacterSelectionCancel(),
|
||||||
|
];
|
||||||
|
Assert.All(accepted, result =>
|
||||||
|
{
|
||||||
|
Assert.True(result.Accepted);
|
||||||
|
Assert.Equal(new RuntimeGenerationToken(11), result.Generation);
|
||||||
|
});
|
||||||
|
|
||||||
|
binding.Dispose();
|
||||||
|
Assert.Null(source.CharacterSelection);
|
||||||
|
Assert.Equal(RuntimeCommandStatus.Inactive,
|
||||||
|
source.CharacterSelectionRestore().Status);
|
||||||
|
|
||||||
|
source.Deactivate();
|
||||||
|
Assert.Null(source.CharacterSelection);
|
||||||
|
Assert.Equal(RuntimeCommandStatus.Inactive,
|
||||||
|
source.CharacterSelectionCancel().Status);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RadarNeverCachesAnUnboundBootstrapSnapshot()
|
public void RadarNeverCachesAnUnboundBootstrapSnapshot()
|
||||||
{
|
{
|
||||||
|
|
@ -216,7 +255,9 @@ public sealed class InteractionUiRuntimeSourcesTests
|
||||||
IGameRuntimeCommands,
|
IGameRuntimeCommands,
|
||||||
IRuntimeInventoryStateCommands,
|
IRuntimeInventoryStateCommands,
|
||||||
IRuntimeSpellbookCommands,
|
IRuntimeSpellbookCommands,
|
||||||
IRuntimeCharacterCommands
|
IRuntimeCharacterCommands,
|
||||||
|
IRuntimeCharacterSelectionView,
|
||||||
|
IRuntimeCharacterSelectionCommands
|
||||||
{
|
{
|
||||||
public RuntimeTarget(RuntimeGenerationToken generation)
|
public RuntimeTarget(RuntimeGenerationToken generation)
|
||||||
{
|
{
|
||||||
|
|
@ -234,6 +275,7 @@ public sealed class InteractionUiRuntimeSourcesTests
|
||||||
public IRuntimeCharacterView Character => null!;
|
public IRuntimeCharacterView Character => null!;
|
||||||
public IRuntimeSocialView Social => null!;
|
public IRuntimeSocialView Social => null!;
|
||||||
public IRuntimeChatView Chat => null!;
|
public IRuntimeChatView Chat => null!;
|
||||||
|
public IRuntimeCharacterSelectionView CharacterSelection => this;
|
||||||
public IRuntimeFellowshipView Fellowship => null!;
|
public IRuntimeFellowshipView Fellowship => null!;
|
||||||
public IRuntimeAllegianceView Allegiance => null!;
|
public IRuntimeAllegianceView Allegiance => null!;
|
||||||
public IRuntimeActionView Actions => null!;
|
public IRuntimeActionView Actions => null!;
|
||||||
|
|
@ -242,6 +284,7 @@ public sealed class InteractionUiRuntimeSourcesTests
|
||||||
null!;
|
null!;
|
||||||
public IRuntimePortalView Portal => null!;
|
public IRuntimePortalView Portal => null!;
|
||||||
public IRuntimeSessionCommands Session => null!;
|
public IRuntimeSessionCommands Session => null!;
|
||||||
|
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => this;
|
||||||
public IRuntimeSelectionCommands Selection => null!;
|
public IRuntimeSelectionCommands Selection => null!;
|
||||||
public IRuntimeCombatCommands Combat => null!;
|
public IRuntimeCombatCommands Combat => null!;
|
||||||
public IRuntimeMagicCommands Magic => null!;
|
public IRuntimeMagicCommands Magic => null!;
|
||||||
|
|
@ -257,6 +300,67 @@ public sealed class InteractionUiRuntimeSourcesTests
|
||||||
|
|
||||||
public RuntimeStateCheckpoint CaptureCheckpoint() => default;
|
public RuntimeStateCheckpoint CaptureCheckpoint() => default;
|
||||||
|
|
||||||
|
RuntimeCharacterSelectionSnapshot IRuntimeCharacterSelectionView.Snapshot =>
|
||||||
|
new(
|
||||||
|
Generation,
|
||||||
|
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
|
||||||
|
Revision: 1,
|
||||||
|
AccountName: "account",
|
||||||
|
SlotCount: 0,
|
||||||
|
RosterCount: 0,
|
||||||
|
HighlightedCharacterId: 0u,
|
||||||
|
HighlightedDisplayIndex: -1,
|
||||||
|
PendingDeleteCharacterId: 0u,
|
||||||
|
LastRestoreRequestedCharacterId: 0u,
|
||||||
|
Operation: RuntimeCharacterSelectionOperation.None,
|
||||||
|
Error: null,
|
||||||
|
Buttons: RuntimeCharacterSelectionButtons.None);
|
||||||
|
|
||||||
|
public bool TryGetAt(
|
||||||
|
int displayIndex,
|
||||||
|
out RuntimeCharacterSelectionEntry character)
|
||||||
|
{
|
||||||
|
character = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGet(
|
||||||
|
uint characterId,
|
||||||
|
out RuntimeCharacterSelectionEntry character)
|
||||||
|
{
|
||||||
|
character = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Visit(IRuntimeCharacterSelectionVisitor visitor) { }
|
||||||
|
|
||||||
|
public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) =>
|
||||||
|
EmptyDisposable.Instance;
|
||||||
|
|
||||||
|
public RuntimeCommandResult Highlight(
|
||||||
|
RuntimeGenerationToken expectedGeneration,
|
||||||
|
uint characterId) => Accepted(expectedGeneration, characterId);
|
||||||
|
|
||||||
|
public RuntimeCommandResult Enter(
|
||||||
|
RuntimeGenerationToken expectedGeneration) =>
|
||||||
|
Accepted(expectedGeneration);
|
||||||
|
|
||||||
|
public RuntimeCommandResult RequestDelete(
|
||||||
|
RuntimeGenerationToken expectedGeneration) =>
|
||||||
|
Accepted(expectedGeneration);
|
||||||
|
|
||||||
|
public RuntimeCommandResult ConfirmDelete(
|
||||||
|
RuntimeGenerationToken expectedGeneration) =>
|
||||||
|
Accepted(expectedGeneration);
|
||||||
|
|
||||||
|
public RuntimeCommandResult Restore(
|
||||||
|
RuntimeGenerationToken expectedGeneration) =>
|
||||||
|
Accepted(expectedGeneration);
|
||||||
|
|
||||||
|
public RuntimeCommandResult Cancel(
|
||||||
|
RuntimeGenerationToken expectedGeneration) =>
|
||||||
|
Accepted(expectedGeneration);
|
||||||
|
|
||||||
public RuntimeCommandResult AddShortcut(
|
public RuntimeCommandResult AddShortcut(
|
||||||
RuntimeGenerationToken expectedGeneration,
|
RuntimeGenerationToken expectedGeneration,
|
||||||
in RuntimeShortcutCommand command) =>
|
in RuntimeShortcutCommand command) =>
|
||||||
|
|
@ -392,6 +496,12 @@ public sealed class InteractionUiRuntimeSourcesTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class EmptyDisposable : IDisposable
|
||||||
|
{
|
||||||
|
public static EmptyDisposable Instance { get; } = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class EmptyRadarSource : ILiveEntityRadarSource
|
private sealed class EmptyRadarSource : ILiveEntityRadarSource
|
||||||
{
|
{
|
||||||
public static EmptyRadarSource Instance { get; } = new();
|
public static EmptyRadarSource Instance { get; } = new();
|
||||||
|
|
|
||||||
|
|
@ -137,12 +137,19 @@ public sealed class SessionPlayerCompositionTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GraphicalCompositionPausesOnlyWhenCharacterSelectorIsAbsent()
|
public void GraphicalCompositionPausesOnlyWhenCharacterSelectorIsAbsent()
|
||||||
{
|
{
|
||||||
|
string root = FindRepoRoot();
|
||||||
string phase = File.ReadAllText(Path.Combine(
|
string phase = File.ReadAllText(Path.Combine(
|
||||||
FindRepoRoot(),
|
root,
|
||||||
"src",
|
"src",
|
||||||
"AcDream.App",
|
"AcDream.App",
|
||||||
"Composition",
|
"Composition",
|
||||||
"SessionPlayerComposition.cs"));
|
"SessionPlayerComposition.cs"));
|
||||||
|
string retainedUi = File.ReadAllText(Path.Combine(
|
||||||
|
root,
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Composition",
|
||||||
|
"InteractionRetainedUiComposition.cs"));
|
||||||
|
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"AwaitCharacterSelection:",
|
"AwaitCharacterSelection:",
|
||||||
|
|
@ -156,6 +163,18 @@ public sealed class SessionPlayerCompositionTests
|
||||||
"CharacterList.TrySelectFirstAvailable",
|
"CharacterList.TrySelectFirstAvailable",
|
||||||
phase,
|
phase,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"CharacterSelection: d.Options.LiveCharacterSelector is null",
|
||||||
|
retainedUi,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"() => late.GameRuntime.CharacterSelection",
|
||||||
|
retainedUi,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"late.GameRuntime.CharacterSelectionEnter",
|
||||||
|
retainedUi,
|
||||||
|
StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class RetryBinding(
|
private sealed class RetryBinding(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
using System.IO;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Content;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using StringTable = DatReaderWriter.DBObjs.StringTable;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Installed-retail-DAT acceptance gate for LA8. Opt in with
|
||||||
|
/// <c>ACDREAM_PROBE_LIVE_MOUNT=1</c>; <c>ACDREAM_DAT_DIR</c> can override the
|
||||||
|
/// ordinary Documents/Asheron's Call location. Reads the DATs read-only.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CharacterManagementLiveDatTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EnumTable5_ResolvesAndImportsTheExactRetailScreenAndDialogs()
|
||||||
|
{
|
||||||
|
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
|
||||||
|
return;
|
||||||
|
|
||||||
|
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
"Documents",
|
||||||
|
"Asheron's Call");
|
||||||
|
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
|
||||||
|
|
||||||
|
const uint expectedLayoutDid = 0x21000004u;
|
||||||
|
uint layoutDid = RetailDataIdResolver.Resolve(
|
||||||
|
dats,
|
||||||
|
CharacterManagementUiController.RootEnum,
|
||||||
|
5u);
|
||||||
|
Assert.Equal(expectedLayoutDid, layoutDid);
|
||||||
|
Console.WriteLine(
|
||||||
|
"[LA8-DAT] category=5 enum=0x10000005 -> DID=0x21000004; "
|
||||||
|
+ "selected-root=0x1000039A");
|
||||||
|
|
||||||
|
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
|
||||||
|
LayoutImporter.ImportInfos(
|
||||||
|
dats,
|
||||||
|
layoutDid,
|
||||||
|
CharacterManagementUiController.RootElementId));
|
||||||
|
Assert.Equal(800f, rootInfo.Width);
|
||||||
|
Assert.Equal(600f, rootInfo.Height);
|
||||||
|
Assert.Equal(8, rootInfo.Children.Count);
|
||||||
|
Assert.Equal(0x06007576u, rootInfo.StateMedia[""].File);
|
||||||
|
ImportedLayout screen = LayoutImporter.Build(
|
||||||
|
rootInfo,
|
||||||
|
_ => (0u, 0, 0),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
new DatStringResolver(dats).Resolve);
|
||||||
|
|
||||||
|
var list = Assert.IsType<UiTemplateListBox>(screen.FindElement(
|
||||||
|
CharacterManagementUiController.ListElementId));
|
||||||
|
UiTemplateListEntry template = Assert.Single(list.Templates);
|
||||||
|
Assert.Equal(expectedLayoutDid, template.TemplateLayoutId);
|
||||||
|
Assert.Equal(0x100003A5u, template.TemplateElementId);
|
||||||
|
AssertButton(screen, CharacterManagementUiController.CreateElementId,
|
||||||
|
"Create Character");
|
||||||
|
AssertButton(screen, CharacterManagementUiController.EnterElementId,
|
||||||
|
"ENTER");
|
||||||
|
AssertButton(screen, CharacterManagementUiController.DeleteElementId,
|
||||||
|
"DELETE");
|
||||||
|
AssertButton(screen, CharacterManagementUiController.RestoreElementId,
|
||||||
|
"RESTORE");
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
Descendants(screen.Root),
|
||||||
|
static element => element is UiViewport);
|
||||||
|
|
||||||
|
ElementInfo rowInfo = Assert.IsType<ElementInfo>(
|
||||||
|
LayoutImporter.ImportInfos(dats, layoutDid, template.TemplateElementId));
|
||||||
|
Assert.Equal(1u, rowInfo.Type);
|
||||||
|
Assert.Equal(160f, rowInfo.Width);
|
||||||
|
Assert.Equal(16f, rowInfo.Height);
|
||||||
|
Assert.Equal(0x40000009u, rowInfo.FontDid);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
UiButtonStateMachine.Normal,
|
||||||
|
UiButtonStateMachine.NormalRollover,
|
||||||
|
UiButtonStateMachine.NormalPressed,
|
||||||
|
UiButtonStateMachine.Highlight,
|
||||||
|
UiButtonStateMachine.HighlightRollover,
|
||||||
|
uint.MaxValue,
|
||||||
|
],
|
||||||
|
rowInfo.States.Keys.Order().ToArray());
|
||||||
|
|
||||||
|
uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u);
|
||||||
|
Assert.Equal(0x2100003Cu, dialogDid);
|
||||||
|
ImportedLayout message = BuildSelected(dats, dialogDid, 0x24u);
|
||||||
|
Assert.IsType<UiDialogRoot>(message.Root);
|
||||||
|
Assert.IsType<UiText>(message.FindElement(0x3Eu));
|
||||||
|
Assert.IsType<UiButton>(message.FindElement(0x26u));
|
||||||
|
ImportedLayout delete = BuildSelected(dats, dialogDid, 0x2Cu);
|
||||||
|
Assert.IsType<UiDialogRoot>(delete.Root);
|
||||||
|
Assert.IsType<UiField>(delete.FindElement(0x2Cu));
|
||||||
|
Assert.IsType<UiButton>(delete.FindElement(0x2Eu));
|
||||||
|
Assert.IsType<UiButton>(delete.FindElement(0x2Fu));
|
||||||
|
|
||||||
|
var strings = new DatStringResolver(dats);
|
||||||
|
const uint table = 0x23000002u;
|
||||||
|
Assert.Equal("DELETE", Resolve(strings, table,
|
||||||
|
"ID_CharacterManagement_DeleteCharacterResponse"));
|
||||||
|
Assert.Equal("Please Wait", Resolve(strings, table,
|
||||||
|
"ID_CharacterManagement_PleaseWait"));
|
||||||
|
Assert.Equal("Entering World", Resolve(strings, table,
|
||||||
|
"ID_Character_EnteringWorld"));
|
||||||
|
string confirmation = Assert.IsType<string>(strings.ResolveTemplate(
|
||||||
|
table,
|
||||||
|
"ID_CharacterManagement_DeleteCharacterConfirmation",
|
||||||
|
new Dictionary<uint, string>
|
||||||
|
{
|
||||||
|
[DatStringResolver.PlayerVariable] = "Test Character",
|
||||||
|
}));
|
||||||
|
Assert.Contains("Test Character", confirmation);
|
||||||
|
Assert.Contains("'DELETE'", confirmation);
|
||||||
|
|
||||||
|
StringTable stringTable = Assert.IsType<StringTable>(dats.Get<StringTable>(table));
|
||||||
|
var deleteEntry = stringTable.Strings[
|
||||||
|
DatStringResolver.ComputeHash(
|
||||||
|
"ID_CharacterManagement_DeleteCharacterConfirmation")];
|
||||||
|
Assert.Equal([DatStringResolver.PlayerVariable], deleteEntry.Variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImportedLayout BuildSelected(
|
||||||
|
IDatReaderWriter dats,
|
||||||
|
uint layoutDid,
|
||||||
|
uint rootId)
|
||||||
|
{
|
||||||
|
ElementInfo info = Assert.IsType<ElementInfo>(
|
||||||
|
LayoutImporter.ImportInfos(dats, layoutDid, rootId));
|
||||||
|
return LayoutImporter.Build(
|
||||||
|
info,
|
||||||
|
_ => (0u, 0, 0),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
new DatStringResolver(dats).Resolve);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Resolve(
|
||||||
|
DatStringResolver strings,
|
||||||
|
uint table,
|
||||||
|
string key) => Assert.IsType<string>(strings.Resolve(
|
||||||
|
table,
|
||||||
|
DatStringResolver.ComputeHash(key)));
|
||||||
|
|
||||||
|
private static void AssertButton(
|
||||||
|
ImportedLayout layout,
|
||||||
|
uint elementId,
|
||||||
|
string label) => Assert.Equal(
|
||||||
|
label,
|
||||||
|
Assert.IsType<UiButton>(layout.FindElement(elementId)).Label);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,716 @@
|
||||||
|
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
|
||||||
|
{
|
||||||
|
[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.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 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");
|
||||||
|
|
||||||
|
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(ButtonInfo(
|
||||||
|
CharacterManagementUiController.CreateElementId));
|
||||||
|
root.Children.Add(ButtonInfo(
|
||||||
|
CharacterManagementUiController.EnterElementId));
|
||||||
|
root.Children.Add(ButtonInfo(
|
||||||
|
CharacterManagementUiController.DeleteElementId));
|
||||||
|
root.Children.Add(ButtonInfo(
|
||||||
|
CharacterManagementUiController.RestoreElementId));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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()
|
||||||
|
{
|
||||||
|
uint id = View.Snapshot.HighlightedCharacterId;
|
||||||
|
Update(snapshot => snapshot with
|
||||||
|
{
|
||||||
|
LastRestoreRequestedCharacterId = id,
|
||||||
|
Operation = RuntimeCharacterSelectionOperation.RestoreRequested,
|
||||||
|
Buttons = new RuntimeCharacterSelectionButtons(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true),
|
||||||
|
});
|
||||||
|
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 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,
|
||||||
|
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() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,8 @@ public class DatWidgetFactoryTests
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(0x13u)] // ConfirmationDialog (catalog root 0x15)
|
[InlineData(0x13u)] // ConfirmationDialog (catalog root 0x15)
|
||||||
|
[InlineData(0x15u)] // ConfirmationTextInputDialog (catalog root 0x2C)
|
||||||
|
[InlineData(0x17u)] // MessageDialog (catalog root 0x24)
|
||||||
[InlineData(0x19u)] // WaitDialog (catalog root 0x31 — OP8 #396's live
|
[InlineData(0x19u)] // WaitDialog (catalog root 0x31 — OP8 #396's live
|
||||||
// crash: unmapped type built a plain UiDatElement and
|
// crash: unmapped type built a plain UiDatElement and
|
||||||
// RetailWaitDialogView's ctor threw out of OnClick)
|
// RetailWaitDialogView's ctor threw out of OnClick)
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,68 @@ public sealed class RetailDialogFactoryTests
|
||||||
Assert.Equal("text", data.GetString(RetailDialogProperty.Message));
|
Assert.Equal("text", data.GetString(RetailDialogProperty.Message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MessageDialog_UsesAuthoredOkButtonAndReturnsThroughFactoryCallback()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||||
|
var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>();
|
||||||
|
using var factory = new RetailDialogFactory(root, type =>
|
||||||
|
{
|
||||||
|
ImportedLayout layout = BuildDialogLayout(type);
|
||||||
|
layouts.Add((type, layout));
|
||||||
|
return layout;
|
||||||
|
});
|
||||||
|
bool completed = false;
|
||||||
|
|
||||||
|
factory.MakeMessage("Character selection failed.", _ => completed = true);
|
||||||
|
|
||||||
|
(RetailDialogType type, ImportedLayout layout) = Assert.Single(layouts);
|
||||||
|
Assert.Equal(RetailDialogType.Message, type);
|
||||||
|
Assert.Equal("Character selection failed.", Message(layout));
|
||||||
|
Button(layout, RetailMessageDialogView.OkButtonId).OnClick!();
|
||||||
|
Assert.True(completed);
|
||||||
|
Assert.False(factory.IsOpen);
|
||||||
|
Assert.Null(root.Modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfirmationTextInput_AcceptsTypedResultAndRejectsWithEmptyResult()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||||
|
var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>();
|
||||||
|
using var factory = new RetailDialogFactory(root, type =>
|
||||||
|
{
|
||||||
|
ImportedLayout layout = BuildDialogLayout(type);
|
||||||
|
layouts.Add((type, layout));
|
||||||
|
return layout;
|
||||||
|
});
|
||||||
|
var results = new List<string>();
|
||||||
|
|
||||||
|
factory.MakeConfirmationTextInput(
|
||||||
|
"Type DELETE.",
|
||||||
|
data => results.Add(
|
||||||
|
data.GetString(RetailDialogProperty.TextInputResult) ?? "<null>"));
|
||||||
|
ImportedLayout accepted = layouts[^1].Layout;
|
||||||
|
var field = Assert.IsType<UiField>(
|
||||||
|
accepted.FindElement(RetailConfirmationTextInputDialogView.InputElementId));
|
||||||
|
Assert.Same(field, root.KeyboardFocus);
|
||||||
|
field.SetText("delete");
|
||||||
|
Button(accepted, RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!();
|
||||||
|
|
||||||
|
factory.MakeConfirmationTextInput(
|
||||||
|
"Type DELETE.",
|
||||||
|
data => results.Add(
|
||||||
|
data.GetString(RetailDialogProperty.TextInputResult) ?? "<null>"));
|
||||||
|
ImportedLayout rejected = layouts[^1].Layout;
|
||||||
|
UiDialogRoot rejectedRoot = Assert.IsType<UiDialogRoot>(rejected.Root);
|
||||||
|
Assert.NotNull(rejectedRoot.Cancel);
|
||||||
|
rejectedRoot.Cancel!();
|
||||||
|
|
||||||
|
Assert.Equal(["delete", ""], results);
|
||||||
|
Assert.False(factory.IsOpen);
|
||||||
|
Assert.Null(root.KeyboardFocus);
|
||||||
|
}
|
||||||
|
|
||||||
private static RetailDialogFactory CreateFactory(
|
private static RetailDialogFactory CreateFactory(
|
||||||
UiRoot root,
|
UiRoot root,
|
||||||
List<ImportedLayout> layouts)
|
List<ImportedLayout> layouts)
|
||||||
|
|
@ -401,4 +463,91 @@ public sealed class RetailDialogFactoryTests
|
||||||
private static string Message(ImportedLayout layout)
|
private static string Message(ImportedLayout layout)
|
||||||
=> string.Join(" ", Assert.IsType<UiText>(layout.FindElement(
|
=> string.Join(" ", Assert.IsType<UiText>(layout.FindElement(
|
||||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
|
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
|
||||||
|
|
||||||
|
internal static ImportedLayout BuildDialogLayout(RetailDialogType type)
|
||||||
|
{
|
||||||
|
uint rootId = RetailDialogFactory.RootElementId(type);
|
||||||
|
uint rootType = type switch
|
||||||
|
{
|
||||||
|
RetailDialogType.Message => 0x17u,
|
||||||
|
RetailDialogType.ConfirmationTextInput => 0x15u,
|
||||||
|
RetailDialogType.Wait => 0x19u,
|
||||||
|
_ => 0x13u,
|
||||||
|
};
|
||||||
|
var root = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = rootId,
|
||||||
|
Type = rootType,
|
||||||
|
Width = 800f,
|
||||||
|
Height = 600f,
|
||||||
|
};
|
||||||
|
var popup = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = 0x3Du,
|
||||||
|
Type = 3u,
|
||||||
|
Width = 400f,
|
||||||
|
Height = type == RetailDialogType.ConfirmationTextInput ? 125f : 95f,
|
||||||
|
};
|
||||||
|
popup.Children.Add(new ElementInfo
|
||||||
|
{
|
||||||
|
Id = 0x3Eu,
|
||||||
|
Type = 12u,
|
||||||
|
X = 15f,
|
||||||
|
Y = 15f,
|
||||||
|
Width = 370f,
|
||||||
|
Height = 18f,
|
||||||
|
});
|
||||||
|
if (type == RetailDialogType.Message)
|
||||||
|
{
|
||||||
|
popup.Children.Add(new ElementInfo
|
||||||
|
{
|
||||||
|
Id = RetailMessageDialogView.OkButtonId,
|
||||||
|
Type = 1u,
|
||||||
|
X = 160f,
|
||||||
|
Y = 48f,
|
||||||
|
Width = 80f,
|
||||||
|
Height = 32f,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (type == RetailDialogType.ConfirmationTextInput)
|
||||||
|
{
|
||||||
|
var field = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = RetailConfirmationTextInputDialogView.InputElementId,
|
||||||
|
Type = 12u,
|
||||||
|
X = 4f,
|
||||||
|
Y = 43f,
|
||||||
|
Width = 152f,
|
||||||
|
Height = 16f,
|
||||||
|
};
|
||||||
|
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||||||
|
direct.Properties.Values[0x16u] = new UiPropertyValue
|
||||||
|
{
|
||||||
|
Kind = UiPropertyKind.Bool,
|
||||||
|
BoolValue = true,
|
||||||
|
};
|
||||||
|
field.States.Add(UiStateInfo.DirectStateId, direct);
|
||||||
|
popup.Children.Add(field);
|
||||||
|
popup.Children.Add(new ElementInfo
|
||||||
|
{
|
||||||
|
Id = RetailConfirmationTextInputDialogView.AcceptButtonId,
|
||||||
|
Type = 1u,
|
||||||
|
X = 80f,
|
||||||
|
Y = 78f,
|
||||||
|
Width = 80f,
|
||||||
|
Height = 32f,
|
||||||
|
});
|
||||||
|
popup.Children.Add(new ElementInfo
|
||||||
|
{
|
||||||
|
Id = RetailConfirmationTextInputDialogView.RejectButtonId,
|
||||||
|
Type = 1u,
|
||||||
|
X = 240f,
|
||||||
|
Y = 78f,
|
||||||
|
Width = 80f,
|
||||||
|
Height = 32f,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root.Children.Add(popup);
|
||||||
|
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,29 @@ public class UiButtonTests
|
||||||
Assert.Equal((17, 9), clicked);
|
Assert.Equal((17, 9), clicked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoubleClick_IsOptInAndDisabledButtonsSwallowWithoutInvoking()
|
||||||
|
{
|
||||||
|
int activations = 0;
|
||||||
|
var button = new UiButton(
|
||||||
|
new ElementInfo { Type = 1, Width = 46, Height = 18 },
|
||||||
|
NoTex);
|
||||||
|
var doubleClick = new UiEvent(
|
||||||
|
0,
|
||||||
|
button,
|
||||||
|
UiEventType.DoubleClick);
|
||||||
|
|
||||||
|
Assert.False(button.OnEvent(doubleClick));
|
||||||
|
|
||||||
|
button.OnDoubleClick = () => activations++;
|
||||||
|
Assert.True(button.OnEvent(doubleClick));
|
||||||
|
Assert.Equal(1, activations);
|
||||||
|
|
||||||
|
button.Enabled = false;
|
||||||
|
Assert.True(button.OnEvent(doubleClick));
|
||||||
|
Assert.Equal(1, activations);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PointerDownAndUp_InvokeDistinctTransitionHandlers()
|
public void PointerDownAndUp_InvokeDistinctTransitionHandlers()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue