From 6cfab727f130422f9247a5ca128a900bf64aacfe Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 20:29:19 +0200 Subject: [PATCH] Implement retail character management screen --- docs/architecture/acdream-architecture.md | 10 + .../2026-08-14-la8-character-management-ui.md | 104 +++ .../InteractionRetainedUiComposition.cs | 15 +- .../InteractionUiRuntimeSources.cs | 48 ++ .../Layout/CharacterManagementUiController.cs | 578 ++++++++++++++ src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 2 + .../RetailConfirmationTextInputDialogView.cs | 160 ++++ src/AcDream.App/UI/Layout/RetailDialogData.cs | 20 + .../UI/Layout/RetailDialogFactory.cs | 33 +- .../UI/Layout/RetailMessageDialogView.cs | 105 +++ src/AcDream.App/UI/RetailUiRuntime.cs | 167 +++- src/AcDream.App/UI/UiButton.cs | 12 + .../InteractionUiRuntimeSourcesTests.cs | 112 ++- .../SessionPlayerCompositionTests.cs | 21 +- .../Layout/CharacterManagementLiveDatTests.cs | 164 ++++ .../CharacterManagementUiControllerTests.cs | 716 ++++++++++++++++++ .../UI/Layout/DatWidgetFactoryTests.cs | 2 + .../UI/Layout/RetailDialogFactoryTests.cs | 149 ++++ tests/AcDream.App.Tests/UI/UiButtonTests.cs | 23 + 19 files changed, 2435 insertions(+), 6 deletions(-) create mode 100644 docs/research/2026-08-14-la8-character-management-ui.md create mode 100644 src/AcDream.App/UI/Layout/CharacterManagementUiController.cs create mode 100644 src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs create mode 100644 src/AcDream.App/UI/Layout/RetailMessageDialogView.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 31ec860b..99ee94a8 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -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 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/ desired/enchantment state projection; Core.Net owns exact manifest and live message parsing; Runtime `RuntimeActionState.SpellCast` owns validated cast diff --git a/docs/research/2026-08-14-la8-character-management-ui.md b/docs/research/2026-08-14-la8-character-management-ui.md new file mode 100644 index 00000000..ac64abb8 --- /dev/null +++ b/docs/research/2026-08-14-la8-character-management-ui.md @@ -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. diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 194eeb91..4b16c5fd 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -21,6 +21,7 @@ using AcDream.Core.Selection; using AcDream.Core.Spells; using AcDream.Runtime; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Vitals; @@ -937,7 +938,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory (action, held) => d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true, 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.CreateUninitialized(bindings)); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 18ea229f..d7ddb795 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -10,6 +10,7 @@ using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Runtime; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions; using Silk.NET.Windowing; @@ -37,6 +38,24 @@ internal sealed class DeferredGameRuntimeStateCommands } } + /// + /// 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 , + /// while CurrentGameRuntimeAdapter keeps an already-borrowed reference + /// inert if disposal races the render-thread consumer. + /// + public IRuntimeCharacterSelectionView? CharacterSelection + { + get + { + lock (_gate) + return !_deactivated && _view is not null + ? _view.CharacterSelection + : null; + } + } + public IDisposable Bind( IGameRuntimeView view, IGameRuntimeCommands commands) @@ -118,6 +137,35 @@ internal sealed class DeferredGameRuntimeStateCommands generation, 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 ───────────────── // Same "capture view+commands under one generation" shape as every // method above — a displaced session (reconnect mid-click) can never diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs new file mode 100644 index 00000000..5a2759a0 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -0,0 +1,578 @@ +using System.Numerics; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// Projects Runtime's one borrowed pre-world character-selection owner through +/// retail gmCharacterManagementUI's authored retained layout. The list is +/// intentionally flat: the retail class owns no viewport or model preview. +/// +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 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 _rows = []; + private readonly Dictionary _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 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 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 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 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; + } + } +} diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 01804e44..5991ba57 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -122,6 +122,8 @@ public static class DatWidgetFactory 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137) 12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text 0x13 => new UiDialogRoot(), // ConfirmationDialog + 0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog + 0x17 => new UiDialogRoot(), // MessageDialog 0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396) 0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots 0x10000035u => BuildCheckbox( diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs new file mode 100644 index 00000000..e21d330b --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs @@ -0,0 +1,160 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Retail type-5 ConfirmationTextInputDialog (class type +/// 0x15, catalog root 0x2C). Accept stores the field text under +/// property 0x9C; reject/Escape stores the empty string. Character +/// deletion is the first consumer and performs retail's case-insensitive +/// comparison with the localized DELETE response in its callback. +/// +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 _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 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 measure = _message.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + IReadOnlyList 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); + } +} diff --git a/src/AcDream.App/UI/Layout/RetailDialogData.cs b/src/AcDream.App/UI/Layout/RetailDialogData.cs index 72512826..d8b61558 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogData.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs @@ -11,6 +11,9 @@ public static class RetailDialogProperty public const uint AcceptLabel = 0x90u; public const uint RejectLabel = 0x91u; public const uint ConfirmationResult = 0x92u; + public const uint TextInputAcceptLabel = 0x9Au; + public const uint TextInputRejectLabel = 0x9Bu; + public const uint TextInputResult = 0x9Cu; /// /// When true, Dialog::SetData @ 0x00476BE0 sets UIElement boolean /// attribute 0x40. The Keystone-owned attribute name is unavailable. @@ -123,4 +126,21 @@ public sealed class RetailDialogData .Set(RetailDialogProperty.ElementAttribute40, true) .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); + } } diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index 554c525c..33a3bf3a 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -148,6 +148,26 @@ public sealed class RetailDialogFactory : IDisposable return MakeDialog(data, callback: null); } + public uint MakeMessage( + string message, + Action? callback = null, + uint queueKey = DefaultQueueKey) + { + RetailDialogData data = RetailDialogData.Message(message) + .Set(RetailDialogProperty.QueueKey, queueKey); + return MakeDialog(data, callback); + } + + public uint MakeConfirmationTextInput( + string message, + Action? callback = null, + uint queueKey = DefaultQueueKey) + { + RetailDialogData data = RetailDialogData.ConfirmationTextInput(message) + .Set(RetailDialogProperty.QueueKey, queueKey); + return MakeDialog(data, callback); + } + /// /// Retail CloseDialog @ 0x00478160. The context can identify an active /// 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) { 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( $"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 { 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( _host, layout, info.Data, info.Context, context => CloseDialog(context)), @@ -294,6 +324,7 @@ public sealed class RetailDialogFactory : IDisposable _host.BringToFront(view.Root); _openOrder.Add(info); _host.Modal = view.Root; + view.Tick(); UpdatePendingDialogDisplays(); DialogOpened?.Invoke(info.Context); } diff --git a/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs new file mode 100644 index 00000000..a9246ac5 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs @@ -0,0 +1,105 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Retail type-3 MessageDialog (class type 0x17, catalog root +/// 0x24). It shares the dialog catalog's popup/message pair with the +/// existing confirmation and wait presenters and closes from its authored OK +/// button 0x26 or Escape. +/// +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 _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 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 measure = _message.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + IReadOnlyList 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); + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index b4164367..df70aa93 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -16,6 +16,7 @@ using AcDream.Core.Selection; using AcDream.Core.Spells; using AcDream.Runtime; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; using AcDream.Content; using AcDream.Core.Input; using AcDream.UI.Abstractions; @@ -369,6 +370,21 @@ public sealed record KeyboardRuntimeBindings( InputDispatcher? Dispatcher, string KeyBindingsFilePath); +/// +/// 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). +/// +public sealed record CharacterSelectionRuntimeBindings( + Func View, + Func Highlight, + Func Enter, + Func RequestDelete, + Func ConfirmDelete, + Func Restore, + Func Cancel); + public sealed record RetailUiRuntimeBindings( UiHost Host, RetailUiAssets Assets, @@ -395,7 +411,8 @@ public sealed record RetailUiRuntimeBindings( BufferedUiRegistry? Plugins, RetailUiPersistenceBindings? Persistence, RetailUiProbeBindings Probe, - KeyboardRuntimeBindings? Keyboard = null); + KeyboardRuntimeBindings? Keyboard = null, + CharacterSelectionRuntimeBindings? CharacterSelection = null); /// /// Composition owner for the production retained gameplay UI. GameWindow supplies @@ -483,6 +500,7 @@ public sealed class RetailUiRuntime : IDisposable MountVendor(); MountSecureTrade(); MountItemCooldowns(); + MountCharacterManagement(); Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged; BindToolbarPanelButtons(); SyncToolbarWindowButtons(); @@ -577,6 +595,7 @@ public sealed class RetailUiRuntime : IDisposable public VendorUiController? VendorController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; } + internal CharacterManagementUiController? CharacterManagementController { get; private set; } public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { @@ -622,6 +641,7 @@ public sealed class RetailUiRuntime : IDisposable ExternalContainerController?.Tick(); SocialPanelController?.Tick(); _itemCooldownController?.Tick(); + CharacterManagementController?.Tick(); DialogFactory?.Tick(); Host.Tick(deltaSeconds); _automation?.Tick(deltaSeconds); @@ -788,6 +808,7 @@ public sealed class RetailUiRuntime : IDisposable { try { + CharacterManagementController?.ResetSession(); DialogFactory?.Reset(); } finally @@ -3669,6 +3690,144 @@ public sealed class RetailUiRuntime : IDisposable "[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 + { + [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 + { + [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() { ItemCooldownAssets? assets; @@ -3712,7 +3871,11 @@ public sealed class RetailUiRuntime : IDisposable } }, () => _itemConfirmationController?.Dispose(), - () => _gameplayConfirmationController?.Dispose(), + () => + { + CharacterManagementController?.Dispose(); + _gameplayConfirmationController?.Dispose(); + }, () => DialogFactory?.Dispose(), _panelUi.Dispose, Host.Dispose); diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 046f2973..b748894b 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -49,6 +49,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize). public Action? OnClick { get; set; } + /// + /// 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). + /// + public Action? OnDoubleClick { get; set; } + /// /// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard /// 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(); OnClickAt?.Invoke(e.Data1, e.Data2); 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: // S6 (2026-08-11 review): unlike Click (whose swallow-when- // disabled is pre-existing, harmless-by-construction behavior diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index 6600398c..c9318931 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -11,6 +11,7 @@ using AcDream.App.World; using AcDream.Core.Net; using AcDream.Core.World; using AcDream.Runtime; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions; namespace AcDream.App.Tests.Composition; @@ -106,6 +107,44 @@ public sealed class InteractionUiRuntimeSourcesTests Assert.Throws(() => 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] public void RadarNeverCachesAnUnboundBootstrapSnapshot() { @@ -216,7 +255,9 @@ public sealed class InteractionUiRuntimeSourcesTests IGameRuntimeCommands, IRuntimeInventoryStateCommands, IRuntimeSpellbookCommands, - IRuntimeCharacterCommands + IRuntimeCharacterCommands, + IRuntimeCharacterSelectionView, + IRuntimeCharacterSelectionCommands { public RuntimeTarget(RuntimeGenerationToken generation) { @@ -234,6 +275,7 @@ public sealed class InteractionUiRuntimeSourcesTests public IRuntimeCharacterView Character => null!; public IRuntimeSocialView Social => null!; public IRuntimeChatView Chat => null!; + public IRuntimeCharacterSelectionView CharacterSelection => this; public IRuntimeFellowshipView Fellowship => null!; public IRuntimeAllegianceView Allegiance => null!; public IRuntimeActionView Actions => null!; @@ -242,6 +284,7 @@ public sealed class InteractionUiRuntimeSourcesTests null!; public IRuntimePortalView Portal => null!; public IRuntimeSessionCommands Session => null!; + IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => this; public IRuntimeSelectionCommands Selection => null!; public IRuntimeCombatCommands Combat => null!; public IRuntimeMagicCommands Magic => null!; @@ -257,6 +300,67 @@ public sealed class InteractionUiRuntimeSourcesTests 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( RuntimeGenerationToken expectedGeneration, 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 { public static EmptyRadarSource Instance { get; } = new(); diff --git a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs index 807c007e..319982fa 100644 --- a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs @@ -137,12 +137,19 @@ public sealed class SessionPlayerCompositionTests [Fact] public void GraphicalCompositionPausesOnlyWhenCharacterSelectorIsAbsent() { + string root = FindRepoRoot(); string phase = File.ReadAllText(Path.Combine( - FindRepoRoot(), + root, "src", "AcDream.App", "Composition", "SessionPlayerComposition.cs")); + string retainedUi = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Composition", + "InteractionRetainedUiComposition.cs")); Assert.Contains( "AwaitCharacterSelection:", @@ -156,6 +163,18 @@ public sealed class SessionPlayerCompositionTests "CharacterList.TrySelectFirstAvailable", phase, 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( diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs new file mode 100644 index 00000000..15c705ae --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -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; + +/// +/// Installed-retail-DAT acceptance gate for LA8. Opt in with +/// ACDREAM_PROBE_LIVE_MOUNT=1; ACDREAM_DAT_DIR can override the +/// ordinary Documents/Asheron's Call location. Reads the DATs read-only. +/// +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( + 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(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( + 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(message.Root); + Assert.IsType(message.FindElement(0x3Eu)); + Assert.IsType(message.FindElement(0x26u)); + ImportedLayout delete = BuildSelected(dats, dialogDid, 0x2Cu); + Assert.IsType(delete.Root); + Assert.IsType(delete.FindElement(0x2Cu)); + Assert.IsType(delete.FindElement(0x2Eu)); + Assert.IsType(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(strings.ResolveTemplate( + table, + "ID_CharacterManagement_DeleteCharacterConfirmation", + new Dictionary + { + [DatStringResolver.PlayerVariable] = "Test Character", + })); + Assert.Contains("Test Character", confirmation); + Assert.Contains("'DELETE'", confirmation); + + StringTable stringTable = Assert.IsType(dats.Get(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( + 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(strings.Resolve( + table, + DatStringResolver.ComputeHash(key))); + + private static void AssertButton( + ImportedLayout layout, + uint elementId, + string label) => Assert.Equal( + label, + Assert.IsType(layout.FindElement(elementId)).Label); + + private static IEnumerable Descendants(UiElement root) + { + yield return root; + foreach (UiElement child in root.Children) + foreach (UiElement descendant in Descendants(child)) + yield return descendant; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs new file mode 100644 index 00000000..93920e74 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -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(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.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 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(layout.FindElement(id)); + + private static UiField Input(ImportedLayout layout) => + Assert.IsType(layout.FindElement( + RetailConfirmationTextInputDialogView.InputElementId)); + + private static string Message(ImportedLayout layout) => string.Join( + " ", + Assert.IsType(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.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(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() + .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 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() { } + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index c06ae755..a77d51ff 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -31,6 +31,8 @@ public class DatWidgetFactoryTests [Theory] [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 // crash: unmapped type built a plain UiDatElement and // RetailWaitDialogView's ctor threw out of OnClick) diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index 75455e7b..5e495c0c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -378,6 +378,68 @@ public sealed class RetailDialogFactoryTests 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(); + + factory.MakeConfirmationTextInput( + "Type DELETE.", + data => results.Add( + data.GetString(RetailDialogProperty.TextInputResult) ?? "")); + ImportedLayout accepted = layouts[^1].Layout; + var field = Assert.IsType( + 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) ?? "")); + ImportedLayout rejected = layouts[^1].Layout; + UiDialogRoot rejectedRoot = Assert.IsType(rejected.Root); + Assert.NotNull(rejectedRoot.Cancel); + rejectedRoot.Cancel!(); + + Assert.Equal(["delete", ""], results); + Assert.False(factory.IsOpen); + Assert.Null(root.KeyboardFocus); + } + private static RetailDialogFactory CreateFactory( UiRoot root, List layouts) @@ -401,4 +463,91 @@ public sealed class RetailDialogFactoryTests private static string Message(ImportedLayout layout) => string.Join(" ", Assert.IsType(layout.FindElement( 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); + } } diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index 4eab1d72..8baaaa38 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -30,6 +30,29 @@ public class UiButtonTests 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] public void PointerDownAndUp_InvokeDistinctTransitionHandlers() {