Harden retail character selection recovery
This commit is contained in:
parent
6cfab727f1
commit
aeac874dab
8 changed files with 736 additions and 81 deletions
|
|
@ -23,7 +23,10 @@ The implementation was derived from
|
|||
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
|
||||
`AddItemFromTemplateList`, then resizes it using signed integer division:
|
||||
`max(listHeight / max(rosterCount, allowedSlots), listHeight / 10)`. Thus a
|
||||
320-pixel list with five allowed slots uses 64-pixel rows, while rosters over
|
||||
ten clamp at 32 pixels. It 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.
|
||||
|
|
@ -55,6 +58,8 @@ 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.
|
||||
When the opt-in flag or installed data is absent, discovery records an explicit
|
||||
skip rather than adding a no-op pass to default suite totals.
|
||||
|
||||
The installed September-2013 data proves:
|
||||
|
||||
|
|
@ -89,11 +94,17 @@ 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.
|
||||
no-reply ends only when Runtime expires its correlation; retail's Please Wait
|
||||
opens before the synchronous restore command and closes immediately if that
|
||||
command rejects or throws. 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. Initial dialog-catalog, character root, and string
|
||||
misses likewise retry on later ticks without mounting a duplicate root or
|
||||
controller. Dialog presenter/catalog failures move their contexts to an
|
||||
internal retry ledger, so UI callbacks do not retain poisoned active/queued
|
||||
entries and the same context can appear after resource recovery.
|
||||
|
||||
There is deliberately no 3D preview and no claimed character-select background
|
||||
scene. The screen root remains neutral with respect to render-loop background
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
private uint _enterWaitContext;
|
||||
private uint _errorDialogContext;
|
||||
private bool _active;
|
||||
private bool _restoreCommandInFlight;
|
||||
private bool _suppressDialogCallbacks;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -197,7 +198,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster))
|
||||
{
|
||||
bool rowsReady;
|
||||
if (RowsMatchRoster(roster))
|
||||
if (RowsMatchRoster(roster, snapshot.SlotCount))
|
||||
{
|
||||
ApplyHighlight(snapshot.HighlightedCharacterId);
|
||||
rowsReady = true;
|
||||
|
|
@ -206,6 +207,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
{
|
||||
rowsReady = RebuildRows(
|
||||
roster,
|
||||
snapshot.SlotCount,
|
||||
snapshot.HighlightedCharacterId);
|
||||
}
|
||||
|
||||
|
|
@ -280,11 +282,16 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
}
|
||||
|
||||
private bool RowsMatchRoster(
|
||||
IReadOnlyList<RuntimeCharacterSelectionEntry> roster)
|
||||
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
|
||||
int allowedSlotCount)
|
||||
{
|
||||
if (_rows.Count != roster.Count)
|
||||
return false;
|
||||
|
||||
int rowHeight = ComputeRowHeight(
|
||||
_list.Height,
|
||||
roster.Count,
|
||||
allowedSlotCount);
|
||||
for (int i = 0; i < roster.Count; i++)
|
||||
{
|
||||
UiButton row = _rows[i];
|
||||
|
|
@ -292,6 +299,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
if (!_rowIds.TryGetValue(row, out uint characterId)
|
||||
|| characterId != character.CharacterId
|
||||
|| !string.Equals(row.Label, character.Name, StringComparison.Ordinal)
|
||||
|| (int)row.Height != rowHeight
|
||||
|| row.LabelColor != (character.IsPendingDelete
|
||||
? new Vector4(1f, 0f, 0f, 1f)
|
||||
: Vector4.One))
|
||||
|
|
@ -305,6 +313,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
|
||||
private bool RebuildRows(
|
||||
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
|
||||
int allowedSlotCount,
|
||||
uint highlightedCharacterId)
|
||||
{
|
||||
foreach (UiButton row in _rows)
|
||||
|
|
@ -316,15 +325,34 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
_rowIds.Clear();
|
||||
_list.Flush();
|
||||
|
||||
bool complete = true;
|
||||
int rowHeight = ComputeRowHeight(
|
||||
_list.Height,
|
||||
roster.Count,
|
||||
allowedSlotCount);
|
||||
_list.LineHeight = rowHeight;
|
||||
bool complete = _list.Templates.Count > 0
|
||||
&& _list.TemplateResolver is not null;
|
||||
foreach (RuntimeCharacterSelectionEntry character in roster)
|
||||
{
|
||||
if (_list.AddItemFromTemplateList(0) is not UiButton row)
|
||||
if (!complete)
|
||||
break;
|
||||
|
||||
UiTemplateListEntry template = _list.Templates[0];
|
||||
if (_list.TemplateResolver!(
|
||||
template.TemplateLayoutId,
|
||||
template.TemplateElementId) is not UiButton row)
|
||||
{
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// AddItemFromTemplateList creates the same template, but its
|
||||
// retained viewport stacks at the template's authored 16px
|
||||
// height. Retail establishes the computed size on every row; our
|
||||
// list fixes Top during insertion, so build and resize first to
|
||||
// make every subsequent Top exact.
|
||||
row.Height = rowHeight;
|
||||
_list.AddPrebuiltRow(row);
|
||||
uint characterId = character.CharacterId;
|
||||
row.Label = character.Name;
|
||||
row.LabelColor = character.IsPendingDelete
|
||||
|
|
@ -354,6 +382,21 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
return false;
|
||||
}
|
||||
|
||||
internal static int ComputeRowHeight(
|
||||
float listHeight,
|
||||
int rosterCount,
|
||||
int allowedSlotCount)
|
||||
{
|
||||
// RebuildCharacterList @ 0x004EC3A0 uses integer UIRegion height and
|
||||
// signed integer division for both terms. The 0x66666667 multiply/
|
||||
// shift sequence is compiler output for height / 10.
|
||||
int height = (int)MathF.Truncate(listHeight);
|
||||
int denominator = Math.Max(rosterCount, allowedSlotCount);
|
||||
if (denominator <= 0)
|
||||
return height / 10;
|
||||
return Math.Max(height / denominator, height / 10);
|
||||
}
|
||||
|
||||
private void ApplyHighlight(uint highlightedCharacterId)
|
||||
{
|
||||
foreach (UiButton row in _rows)
|
||||
|
|
@ -407,9 +450,39 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
RuntimeCommandResult result = _bindings.Restore();
|
||||
if (result.Accepted)
|
||||
EnsureOperationWait();
|
||||
|
||||
// ListenToElementMessage @ 0x004ED5A0 opens Please Wait before it
|
||||
// calls CPlayerSystem::RestoreCharacter. Keep it modal even if a
|
||||
// synchronous command callback re-enters Tick before Runtime has
|
||||
// returned its accepted projection.
|
||||
EnsureOperationWait();
|
||||
RuntimeCommandResult result = default;
|
||||
Exception? failure = null;
|
||||
_restoreCommandInFlight = true;
|
||||
try
|
||||
{
|
||||
result = _bindings.Restore();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
failure = error;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_restoreCommandInFlight = false;
|
||||
}
|
||||
|
||||
if (failure is not null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[UI] character restore command failed: {failure.Message}");
|
||||
CloseContext(ref _operationWaitContext, suppressCallback: true);
|
||||
InvalidateAndTick();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Accepted)
|
||||
CloseContext(ref _operationWaitContext, suppressCallback: true);
|
||||
InvalidateAndTick();
|
||||
}
|
||||
|
||||
|
|
@ -446,7 +519,8 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
CloseContext(ref _deleteDialogContext, suppressCallback: true);
|
||||
}
|
||||
|
||||
if (snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
|
||||
if (_restoreCommandInFlight
|
||||
|| snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
|
||||
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
||||
or RuntimeCharacterSelectionOperation.RestoreRequested)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
internal sealed record CharacterManagementUiMountResources(
|
||||
uint LayoutId,
|
||||
ImportedLayout Layout,
|
||||
Func<uint, uint, UiElement?> TemplateResolver,
|
||||
CharacterManagementUiController.DialogStrings Strings);
|
||||
|
||||
/// <summary>
|
||||
/// Retryable, idempotent composition edge for the pre-world character screen.
|
||||
/// DATs can become readable after the graphical runtime starts (installer copy,
|
||||
/// mapped-file replacement, or a transient catalog miss), so an unavailable
|
||||
/// dialog catalog, root, template, or string must not permanently suppress the
|
||||
/// screen. Once bound, later ticks are no-ops and cannot duplicate the root or
|
||||
/// controller lifetime.
|
||||
/// </summary>
|
||||
internal sealed class CharacterManagementUiMountCoordinator : IDisposable
|
||||
{
|
||||
private readonly UiRoot _host;
|
||||
private readonly CharacterSelectionRuntimeBindings _bindings;
|
||||
private readonly Func<RetailDialogFactory?> _ensureDialogs;
|
||||
private readonly Func<CharacterManagementUiMountResources?> _loadResources;
|
||||
private bool _disposed;
|
||||
|
||||
public CharacterManagementUiMountCoordinator(
|
||||
UiRoot host,
|
||||
CharacterSelectionRuntimeBindings bindings,
|
||||
Func<RetailDialogFactory?> ensureDialogs,
|
||||
Func<CharacterManagementUiMountResources?> loadResources)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
|
||||
_ensureDialogs = ensureDialogs
|
||||
?? throw new ArgumentNullException(nameof(ensureDialogs));
|
||||
_loadResources = loadResources
|
||||
?? throw new ArgumentNullException(nameof(loadResources));
|
||||
}
|
||||
|
||||
public CharacterManagementUiController? Controller { get; private set; }
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (_disposed || Controller is not null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
RetailDialogFactory? dialogs = _ensureDialogs();
|
||||
if (dialogs is null)
|
||||
return;
|
||||
|
||||
CharacterManagementUiMountResources? resources = _loadResources();
|
||||
if (resources is null)
|
||||
return;
|
||||
|
||||
Controller = CharacterManagementUiController.Bind(
|
||||
_host,
|
||||
resources.Layout,
|
||||
resources.TemplateResolver,
|
||||
dialogs,
|
||||
_bindings,
|
||||
resources.Strings);
|
||||
if (Controller is not null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[UI] retail character management from enum table 5 "
|
||||
+ $"(0x10000005 -> 0x{resources.LayoutId:X8}, "
|
||||
+ "root 0x1000039A; flat list, no viewport).");
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] character management mount will retry after resource "
|
||||
+ $"recovery: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
Controller?.Dispose();
|
||||
Controller = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
private readonly Dictionary<uint, DialogInfo> _activeQueued = new();
|
||||
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
|
||||
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
|
||||
private readonly LinkedList<DialogInfo> _retryable = new();
|
||||
private readonly List<DialogInfo> _openOrder = new();
|
||||
private uint _globalContext;
|
||||
private bool _resetting;
|
||||
|
|
@ -51,6 +52,8 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
|
||||
public int PendingCount => _pending.Values.Sum(static queue => queue.Count);
|
||||
|
||||
internal int RetryCount => _retryable.Count;
|
||||
|
||||
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
|
||||
public static uint RootElementId(RetailDialogType type)
|
||||
=> type switch
|
||||
|
|
@ -93,14 +96,28 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
if (queueKey == NonQueuedKey)
|
||||
{
|
||||
_activeNonQueued.Add(context, info);
|
||||
CreateDialog(info);
|
||||
if (!TryCreateDialog(info))
|
||||
{
|
||||
_activeNonQueued.Remove(context);
|
||||
QueueRetry(info);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
|
||||
{
|
||||
if (HasRetry(queueKey))
|
||||
{
|
||||
PendingQueue(queueKey).AddLast(info);
|
||||
return context;
|
||||
}
|
||||
|
||||
_activeQueued.Add(queueKey, info);
|
||||
CreateDialog(info);
|
||||
if (!TryCreateDialog(info))
|
||||
{
|
||||
_activeQueued.Remove(queueKey);
|
||||
QueueRetry(info);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +135,15 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
Suspend(current);
|
||||
queue.AddFirst(current);
|
||||
_activeQueued[queueKey] = info;
|
||||
CreateDialog(info);
|
||||
if (!TryCreateDialog(info))
|
||||
{
|
||||
_activeQueued.Remove(queueKey);
|
||||
queue.Remove(current);
|
||||
if (queue.Count == 0)
|
||||
_pending.Remove(queueKey);
|
||||
OpenSpecificDialog(current);
|
||||
QueueRetry(info);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
|
|
@ -211,11 +236,25 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
LinkedListNode<DialogInfo>? retry = _retryable.First;
|
||||
while (retry is not null && retry.Value.Context != context)
|
||||
retry = retry.Next;
|
||||
if (retry is not null)
|
||||
{
|
||||
DialogInfo failed = retry.Value;
|
||||
_retryable.Remove(retry);
|
||||
DialogDone(failed);
|
||||
if (failed.QueueKey != NonQueuedKey)
|
||||
OpenNextDialog(failed.QueueKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
RetryFailedDialogs();
|
||||
foreach (DialogInfo info in _openOrder.ToArray())
|
||||
info.View?.Tick();
|
||||
}
|
||||
|
|
@ -238,6 +277,7 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
DialogInfo[] infos = _activeNonQueued.Values
|
||||
.Concat(_activeQueued.Values)
|
||||
.Concat(_pending.Values.SelectMany(static queue => queue))
|
||||
.Concat(_retryable)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (infos.Length == 0)
|
||||
|
|
@ -249,6 +289,7 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
_activeNonQueued.Clear();
|
||||
_activeQueued.Clear();
|
||||
_pending.Clear();
|
||||
_retryable.Clear();
|
||||
foreach (DialogInfo info in infos)
|
||||
{
|
||||
try { DialogDone(info); }
|
||||
|
|
@ -292,41 +333,57 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
return queue;
|
||||
}
|
||||
|
||||
private void CreateDialog(DialogInfo info)
|
||||
private bool TryCreateDialog(DialogInfo info)
|
||||
{
|
||||
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
|
||||
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.");
|
||||
|
||||
ImportedLayout layout = _createLayout(type)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Retail dialog catalog could not create type {(uint)type}.");
|
||||
IRetailDialogView view = type switch
|
||||
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(
|
||||
RetailDialogProperty.Type);
|
||||
try
|
||||
{
|
||||
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(
|
||||
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.");
|
||||
}
|
||||
|
||||
ImportedLayout layout = _createLayout(type)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Retail dialog catalog could not create type {(uint)type}.");
|
||||
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)),
|
||||
_ => new RetailConfirmationDialogView(
|
||||
_host, layout, info.Data, info.Context,
|
||||
context => CloseDialog(context)),
|
||||
};
|
||||
info.View = view;
|
||||
_host.AddChild(view.Root);
|
||||
_host.BringToFront(view.Root);
|
||||
_openOrder.Add(info);
|
||||
_host.Modal = view.Root;
|
||||
view.Tick();
|
||||
UpdatePendingDialogDisplays();
|
||||
DialogOpened?.Invoke(info.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)),
|
||||
};
|
||||
info.View = view;
|
||||
_host.AddChild(view.Root);
|
||||
_host.BringToFront(view.Root);
|
||||
_openOrder.Add(info);
|
||||
_host.Modal = view.Root;
|
||||
view.Tick();
|
||||
UpdatePendingDialogDisplays();
|
||||
DialogOpened?.Invoke(info.Context);
|
||||
return true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RemoveView(info);
|
||||
Console.WriteLine(
|
||||
$"[UI] retail dialog type {(uint)type} context {info.Context} "
|
||||
+ $"will retry after catalog recovery: {error.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Suspend(DialogInfo info)
|
||||
|
|
@ -380,6 +437,9 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
if (_activeQueued.ContainsKey(queueKey))
|
||||
return;
|
||||
|
||||
if (TryActivateRetry(queueKey))
|
||||
return;
|
||||
|
||||
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|
||||
|| queue.First is null)
|
||||
return;
|
||||
|
|
@ -389,7 +449,73 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
if (queue.Count == 0)
|
||||
_pending.Remove(queueKey);
|
||||
_activeQueued.Add(queueKey, next);
|
||||
CreateDialog(next);
|
||||
if (!TryCreateDialog(next))
|
||||
{
|
||||
_activeQueued.Remove(queueKey);
|
||||
QueueRetry(next);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenSpecificDialog(DialogInfo info)
|
||||
{
|
||||
_activeQueued.Add(info.QueueKey, info);
|
||||
if (!TryCreateDialog(info))
|
||||
{
|
||||
_activeQueued.Remove(info.QueueKey);
|
||||
QueueRetry(info);
|
||||
}
|
||||
}
|
||||
|
||||
private void RetryFailedDialogs()
|
||||
{
|
||||
foreach (DialogInfo info in _retryable.ToArray())
|
||||
{
|
||||
if (info.QueueKey == NonQueuedKey)
|
||||
{
|
||||
_activeNonQueued.Add(info.Context, info);
|
||||
if (TryCreateDialog(info))
|
||||
_retryable.Remove(info);
|
||||
else
|
||||
_activeNonQueued.Remove(info.Context);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_activeQueued.ContainsKey(info.QueueKey)
|
||||
&& ReferenceEquals(FirstRetry(info.QueueKey), info))
|
||||
{
|
||||
TryActivateRetry(info.QueueKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryActivateRetry(uint queueKey)
|
||||
{
|
||||
DialogInfo? info = FirstRetry(queueKey);
|
||||
if (info is null)
|
||||
return false;
|
||||
|
||||
_activeQueued.Add(queueKey, info);
|
||||
if (TryCreateDialog(info))
|
||||
_retryable.Remove(info);
|
||||
else
|
||||
_activeQueued.Remove(queueKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
private DialogInfo? FirstRetry(uint queueKey)
|
||||
{
|
||||
foreach (DialogInfo info in _retryable)
|
||||
if (info.QueueKey == queueKey)
|
||||
return info;
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool HasRetry(uint queueKey) => FirstRetry(queueKey) is not null;
|
||||
|
||||
private void QueueRetry(DialogInfo info)
|
||||
{
|
||||
if (!_retryable.Contains(info))
|
||||
_retryable.AddLast(info);
|
||||
}
|
||||
|
||||
private void UpdatePendingDialogDisplays()
|
||||
|
|
|
|||
|
|
@ -434,6 +434,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
||||
private ItemCooldownUiController? _itemCooldownController;
|
||||
private VividTargetIndicatorController? _vividTargetIndicator;
|
||||
private CharacterManagementUiMountCoordinator? _characterManagementMount;
|
||||
private IDisposable? _characterSheetSubscription;
|
||||
private ResourceShutdownTransaction? _shutdown;
|
||||
private bool _disposed;
|
||||
|
|
@ -500,7 +501,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountVendor();
|
||||
MountSecureTrade();
|
||||
MountItemCooldowns();
|
||||
MountCharacterManagement();
|
||||
ConfigureCharacterManagement();
|
||||
_characterManagementMount?.Tick();
|
||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||
BindToolbarPanelButtons();
|
||||
SyncToolbarWindowButtons();
|
||||
|
|
@ -595,7 +597,8 @@ 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; }
|
||||
internal CharacterManagementUiController? CharacterManagementController =>
|
||||
_characterManagementMount?.Controller;
|
||||
|
||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
|
|
@ -641,6 +644,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ExternalContainerController?.Tick();
|
||||
SocialPanelController?.Tick();
|
||||
_itemCooldownController?.Tick();
|
||||
_characterManagementMount?.Tick();
|
||||
CharacterManagementController?.Tick();
|
||||
DialogFactory?.Tick();
|
||||
Host.Tick(deltaSeconds);
|
||||
|
|
@ -3051,13 +3055,29 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
private void MountDialogFactory()
|
||||
{
|
||||
if (DialogFactory is not null)
|
||||
return;
|
||||
|
||||
uint layoutId;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
try
|
||||
{
|
||||
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
|
||||
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
|
||||
// creates a fresh type-specific root from that catalog.
|
||||
layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u);
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
|
||||
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
|
||||
// creates a fresh type-specific root from that catalog.
|
||||
layoutId = RetailDataIdResolver.Resolve(
|
||||
_bindings.Assets.Dats,
|
||||
2u,
|
||||
5u);
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] retail dialog catalog will retry after resource "
|
||||
+ $"recovery: {error.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (layoutId == 0u)
|
||||
|
|
@ -3690,19 +3710,28 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
|
||||
}
|
||||
|
||||
private void MountCharacterManagement()
|
||||
private void ConfigureCharacterManagement()
|
||||
{
|
||||
CharacterSelectionRuntimeBindings? bindings =
|
||||
_bindings.CharacterSelection;
|
||||
if (bindings is null)
|
||||
if (bindings is null || _characterManagementMount is not null)
|
||||
return;
|
||||
if (DialogFactory is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] character management: retail DialogFactory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
_characterManagementMount = new CharacterManagementUiMountCoordinator(
|
||||
Host.Root,
|
||||
bindings,
|
||||
EnsureDialogFactory,
|
||||
LoadCharacterManagementResources);
|
||||
}
|
||||
|
||||
private RetailDialogFactory? EnsureDialogFactory()
|
||||
{
|
||||
MountDialogFactory();
|
||||
return DialogFactory;
|
||||
}
|
||||
|
||||
private CharacterManagementUiMountResources? LoadCharacterManagementResources()
|
||||
{
|
||||
const uint stringTableId = 0x23000002u;
|
||||
uint layoutId;
|
||||
ImportedLayout? layout;
|
||||
|
|
@ -3730,7 +3759,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
Console.WriteLine(
|
||||
"[UI] character management: enum-table-5 root could not be imported.");
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
string? deleteResponse;
|
||||
|
|
@ -3767,7 +3796,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
Console.WriteLine(
|
||||
"[UI] character management: required retail strings are unavailable.");
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
|
||||
|
|
@ -3798,23 +3827,15 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
CharacterManagementController = CharacterManagementUiController.Bind(
|
||||
Host.Root,
|
||||
return new CharacterManagementUiMountResources(
|
||||
layoutId,
|
||||
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(
|
||||
|
|
@ -3873,7 +3894,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
() => _itemConfirmationController?.Dispose(),
|
||||
() =>
|
||||
{
|
||||
CharacterManagementController?.Dispose();
|
||||
_characterManagementMount?.Dispose();
|
||||
_gameplayConfirmationController?.Dispose();
|
||||
},
|
||||
() => DialogFactory?.Dispose(),
|
||||
|
|
|
|||
|
|
@ -15,12 +15,9 @@ namespace AcDream.App.Tests.UI.Layout;
|
|||
/// </summary>
|
||||
public sealed class CharacterManagementLiveDatTests
|
||||
{
|
||||
[Fact]
|
||||
[InstalledDatFact]
|
||||
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),
|
||||
|
|
@ -162,3 +159,23 @@ public sealed class CharacterManagementLiveDatTests
|
|||
yield return descendant;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InstalledDatFactAttribute : FactAttribute
|
||||
{
|
||||
public InstalledDatFactAttribute()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
|
||||
{
|
||||
Skip = "Set ACDREAM_PROBE_LIVE_MOUNT=1 to run the installed-DAT LA8 gate.";
|
||||
return;
|
||||
}
|
||||
|
||||
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
if (!File.Exists(Path.Combine(datDirectory, "client_portal.dat")))
|
||||
Skip = $"Installed client_portal.dat is required at '{datDirectory}'.";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ public sealed class CharacterManagementUiControllerTests
|
|||
Assert.Equal(
|
||||
["Alpha", "Zulu", "Aaron (pending)"],
|
||||
controller.Rows.Select(static row => row.Label!).ToArray());
|
||||
Assert.All(controller.Rows, static row => Assert.Equal(64f, row.Height));
|
||||
Assert.Equal([0f, 64f, 128f],
|
||||
controller.Rows.Select(static row => row.Top).ToArray());
|
||||
Assert.True(controller.Rows[0].Selected);
|
||||
Assert.False(controller.Rows[1].Selected);
|
||||
Assert.Equal(Vector4.One, controller.Rows[0].LabelColor);
|
||||
|
|
@ -65,6 +68,126 @@ public sealed class CharacterManagementUiControllerTests
|
|||
Assert.True(restore.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
CharacterManagementUiController controller = environment.Controller;
|
||||
|
||||
Assert.Equal(64, CharacterManagementUiController.ComputeRowHeight(
|
||||
320f,
|
||||
rosterCount: 3,
|
||||
allowedSlotCount: 5));
|
||||
Assert.Equal(63, CharacterManagementUiController.ComputeRowHeight(
|
||||
319f,
|
||||
rosterCount: 5,
|
||||
allowedSlotCount: 5));
|
||||
Assert.Equal(31, CharacterManagementUiController.ComputeRowHeight(
|
||||
319f,
|
||||
rosterCount: 11,
|
||||
allowedSlotCount: 5));
|
||||
|
||||
RuntimeCharacterSelectionEntry[] large = Enumerable.Range(0, 12)
|
||||
.Select(index => new RuntimeCharacterSelectionEntry(
|
||||
index,
|
||||
(uint)(0x50000100 + index),
|
||||
$"Character {index:D2}",
|
||||
0u))
|
||||
.ToArray();
|
||||
environment.Runtime.ReplaceRoster(
|
||||
large,
|
||||
highlightedCharacterId: large[0].CharacterId);
|
||||
controller.Tick();
|
||||
|
||||
Assert.Equal(12, controller.Rows.Count);
|
||||
Assert.All(controller.Rows, static row => Assert.Equal(32f, row.Height));
|
||||
Assert.Equal(
|
||||
Enumerable.Range(0, 12).Select(static index => index * 32f),
|
||||
controller.Rows.Select(static row => row.Top));
|
||||
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
|
||||
environment.Screen.FindElement(
|
||||
CharacterManagementUiController.ListElementId));
|
||||
Assert.Equal(384, list.ContentHeight);
|
||||
Assert.Equal(32, list.LineHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MountCoordinator_RetriesCatalogRootAndStrings_ThenMountsOnce()
|
||||
{
|
||||
var host = new UiRoot();
|
||||
var runtime = new FakeRuntime();
|
||||
using var dialogs = new RetailDialogFactory(
|
||||
host,
|
||||
RetailDialogFactoryTests.BuildDialogLayout);
|
||||
bool catalogAvailable = false;
|
||||
bool rootAvailable = false;
|
||||
bool stringsAvailable = false;
|
||||
int dialogAttempts = 0;
|
||||
int resourceAttempts = 0;
|
||||
using var coordinator = new CharacterManagementUiMountCoordinator(
|
||||
host,
|
||||
runtime.Bindings,
|
||||
() =>
|
||||
{
|
||||
dialogAttempts++;
|
||||
return catalogAvailable ? dialogs : null;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
resourceAttempts++;
|
||||
if (!rootAvailable || !stringsAvailable)
|
||||
return null;
|
||||
return new CharacterManagementUiMountResources(
|
||||
0x21000004u,
|
||||
BuildScreen(),
|
||||
static (layoutId, elementId) =>
|
||||
layoutId == 0x21000004u
|
||||
&& elementId == 0x100003A5u
|
||||
? BuildRow()
|
||||
: null,
|
||||
TestStrings());
|
||||
});
|
||||
|
||||
coordinator.Tick();
|
||||
Assert.Null(coordinator.Controller);
|
||||
Assert.Equal(1, dialogAttempts);
|
||||
Assert.Equal(0, resourceAttempts);
|
||||
|
||||
catalogAvailable = true;
|
||||
coordinator.Tick();
|
||||
Assert.Null(coordinator.Controller);
|
||||
Assert.Equal(2, dialogAttempts);
|
||||
Assert.Equal(1, resourceAttempts);
|
||||
|
||||
rootAvailable = true;
|
||||
coordinator.Tick();
|
||||
Assert.Null(coordinator.Controller);
|
||||
Assert.Equal(3, dialogAttempts);
|
||||
Assert.Equal(2, resourceAttempts);
|
||||
|
||||
stringsAvailable = true;
|
||||
coordinator.Tick();
|
||||
CharacterManagementUiController controller = Assert.IsType<
|
||||
CharacterManagementUiController>(coordinator.Controller);
|
||||
Assert.Single(host.Children);
|
||||
Assert.Same(controller.Root, host.Children[0]);
|
||||
Assert.Equal(4, dialogAttempts);
|
||||
Assert.Equal(3, resourceAttempts);
|
||||
|
||||
coordinator.Tick();
|
||||
Assert.Same(controller, coordinator.Controller);
|
||||
Assert.Single(host.Children);
|
||||
Assert.Equal(4, dialogAttempts);
|
||||
Assert.Equal(3, resourceAttempts);
|
||||
|
||||
coordinator.Dispose();
|
||||
Assert.Empty(host.Children);
|
||||
coordinator.Tick();
|
||||
Assert.Null(coordinator.Controller);
|
||||
Assert.Equal(4, dialogAttempts);
|
||||
Assert.Equal(3, resourceAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster()
|
||||
{
|
||||
|
|
@ -218,6 +341,78 @@ public sealed class CharacterManagementUiControllerTests
|
|||
Assert.False(environment.Dialogs.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Restore_OpensWaitBeforeCommand_AndKeepsOneModalAcrossReentrantTick()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
CharacterManagementUiController controller = environment.Controller;
|
||||
controller.Rows[2].OnClick!();
|
||||
uint observedContext = 0u;
|
||||
environment.Runtime.BeforeRestore = () =>
|
||||
{
|
||||
observedContext = controller.OperationWaitContext;
|
||||
Assert.NotEqual(0u, observedContext);
|
||||
Assert.True(environment.Dialogs.IsOpen);
|
||||
Assert.Same(
|
||||
environment.LastDialog(RetailDialogType.Wait).Root,
|
||||
environment.Host.Modal);
|
||||
|
||||
// A synchronous callback can pump the presentation before the
|
||||
// command has returned. The in-flight edge must retain the one
|
||||
// wait context instead of closing/reopening it.
|
||||
controller.Tick();
|
||||
Assert.Equal(observedContext, controller.OperationWaitContext);
|
||||
};
|
||||
environment.Runtime.AfterRestoreProjection = () =>
|
||||
{
|
||||
controller.Tick();
|
||||
Assert.Equal(observedContext, controller.OperationWaitContext);
|
||||
};
|
||||
|
||||
environment.Button(
|
||||
CharacterManagementUiController.RestoreElementId).OnClick!();
|
||||
|
||||
Assert.Equal(1, environment.Runtime.RestoreCalls);
|
||||
Assert.Equal(observedContext, controller.OperationWaitContext);
|
||||
Assert.Equal(
|
||||
1,
|
||||
environment.DialogLayouts.Count(static entry =>
|
||||
entry.Type == RetailDialogType.Wait));
|
||||
Assert.Same(
|
||||
environment.LastDialog(RetailDialogType.Wait).Root,
|
||||
environment.Host.Modal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Restore_ImmediateRejectionOrFailure_ClosesPreopenedWait(
|
||||
bool throwFailure)
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
CharacterManagementUiController controller = environment.Controller;
|
||||
controller.Rows[2].OnClick!();
|
||||
environment.Runtime.RestoreStatus = RuntimeCommandStatus.Rejected;
|
||||
environment.Runtime.ThrowOnRestore = throwFailure;
|
||||
environment.Runtime.BeforeRestore = () =>
|
||||
{
|
||||
Assert.NotEqual(0u, controller.OperationWaitContext);
|
||||
Assert.NotNull(environment.Host.Modal);
|
||||
};
|
||||
|
||||
Exception? error = Record.Exception(() => environment.Button(
|
||||
CharacterManagementUiController.RestoreElementId).OnClick!());
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Equal(1, environment.Runtime.RestoreCalls);
|
||||
Assert.Equal(0u, controller.OperationWaitContext);
|
||||
Assert.False(environment.Dialogs.IsOpen);
|
||||
Assert.Null(environment.Host.Modal);
|
||||
Assert.Equal(
|
||||
RuntimeCharacterSelectionOperation.None,
|
||||
environment.Runtime.View.Snapshot.Operation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingOrDisposedBorrowedView_ClosesDialogsFlushesRowsAndDisposesSafely()
|
||||
{
|
||||
|
|
@ -475,6 +670,12 @@ public sealed class CharacterManagementUiControllerTests
|
|||
public int EnterCalls { get; private set; }
|
||||
public int ConfirmDeleteCalls { get; private set; }
|
||||
public int CancelCalls { get; private set; }
|
||||
public int RestoreCalls { get; private set; }
|
||||
public RuntimeCommandStatus RestoreStatus { get; set; } =
|
||||
RuntimeCommandStatus.Accepted;
|
||||
public bool ThrowOnRestore { get; set; }
|
||||
public Action? BeforeRestore { get; set; }
|
||||
public Action? AfterRestoreProjection { get; set; }
|
||||
|
||||
public void SetOperation(RuntimeCharacterSelectionOperation operation)
|
||||
{
|
||||
|
|
@ -578,7 +779,13 @@ public sealed class CharacterManagementUiControllerTests
|
|||
|
||||
private RuntimeCommandResult Restore()
|
||||
{
|
||||
RestoreCalls++;
|
||||
uint id = View.Snapshot.HighlightedCharacterId;
|
||||
BeforeRestore?.Invoke();
|
||||
if (ThrowOnRestore)
|
||||
throw new InvalidOperationException("restore transport failed");
|
||||
if (RestoreStatus != RuntimeCommandStatus.Accepted)
|
||||
return Result(RestoreStatus, id);
|
||||
Update(snapshot => snapshot with
|
||||
{
|
||||
LastRestoreRequestedCharacterId = id,
|
||||
|
|
@ -590,6 +797,7 @@ public sealed class CharacterManagementUiControllerTests
|
|||
false,
|
||||
true),
|
||||
});
|
||||
AfterRestoreProjection?.Invoke();
|
||||
return Result(RuntimeCommandStatus.Accepted, id);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -440,6 +440,108 @@ public sealed class RetailDialogFactoryTests
|
|||
Assert.Null(root.KeyboardFocus);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RetailDialogType.Wait, 0)]
|
||||
[InlineData(RetailDialogType.Message, 1)]
|
||||
[InlineData(RetailDialogType.ConfirmationTextInput, 2)]
|
||||
public void CatalogFailure_DoesNotPoisonActiveQueue_AndTickRecovers(
|
||||
RetailDialogType type,
|
||||
int failureKind)
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
bool available = false;
|
||||
int attempts = 0;
|
||||
using var factory = new RetailDialogFactory(root, requested =>
|
||||
{
|
||||
Assert.Equal(type, requested);
|
||||
attempts++;
|
||||
if (!available)
|
||||
{
|
||||
return failureKind switch
|
||||
{
|
||||
0 => throw new InvalidOperationException("catalog unavailable"),
|
||||
1 => null,
|
||||
_ => new ImportedLayout(
|
||||
new UiDialogRoot(),
|
||||
new Dictionary<uint, UiElement>()),
|
||||
};
|
||||
}
|
||||
return BuildDialogLayout(type);
|
||||
});
|
||||
RetailDialogData data = type switch
|
||||
{
|
||||
RetailDialogType.Wait => RetailDialogData.Wait("Please Wait"),
|
||||
RetailDialogType.Message => RetailDialogData.Message("Error"),
|
||||
_ => RetailDialogData.ConfirmationTextInput("Type DELETE"),
|
||||
};
|
||||
|
||||
uint context = 0u;
|
||||
Exception? creationError = Record.Exception(
|
||||
() => context = factory.MakeDialog(data));
|
||||
|
||||
Assert.Null(creationError);
|
||||
Assert.NotEqual(0u, context);
|
||||
Assert.Equal(0, factory.ActiveCount);
|
||||
Assert.Equal(0, factory.PendingCount);
|
||||
Assert.Equal(1, factory.RetryCount);
|
||||
Assert.Null(root.Modal);
|
||||
Assert.Empty(root.Children);
|
||||
|
||||
available = true;
|
||||
factory.Tick();
|
||||
|
||||
Assert.Equal(2, attempts);
|
||||
Assert.Equal(1, factory.ActiveCount);
|
||||
Assert.Equal(0, factory.PendingCount);
|
||||
Assert.Equal(0, factory.RetryCount);
|
||||
Assert.NotNull(root.Modal);
|
||||
Assert.True(factory.CloseDialog(context));
|
||||
Assert.False(factory.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingCatalogFailure_MovesOutOfQueue_ThenRecoversBeforeLaterWork()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
bool messageAvailable = false;
|
||||
var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>();
|
||||
using var factory = new RetailDialogFactory(root, type =>
|
||||
{
|
||||
if (type == RetailDialogType.Message && !messageAvailable)
|
||||
return null;
|
||||
ImportedLayout layout = BuildDialogLayout(type);
|
||||
layouts.Add((type, layout));
|
||||
return layout;
|
||||
});
|
||||
|
||||
uint active = factory.MakeWait("active");
|
||||
uint failed = factory.MakeMessage("recover me");
|
||||
uint later = factory.MakeWait("later");
|
||||
Assert.Equal(2, factory.PendingCount);
|
||||
|
||||
Assert.True(factory.CloseDialog(active));
|
||||
|
||||
Assert.Equal(0, factory.ActiveCount);
|
||||
Assert.Equal(1, factory.PendingCount);
|
||||
Assert.Equal(1, factory.RetryCount);
|
||||
Assert.Null(root.Modal);
|
||||
|
||||
messageAvailable = true;
|
||||
factory.Tick();
|
||||
|
||||
Assert.Equal(1, factory.ActiveCount);
|
||||
Assert.Equal(1, factory.PendingCount);
|
||||
Assert.Equal(0, factory.RetryCount);
|
||||
Assert.Equal(
|
||||
"recover me",
|
||||
MessageFromAnyDialog(layouts.Last(static entry =>
|
||||
entry.Type == RetailDialogType.Message).Layout.Root));
|
||||
|
||||
Assert.True(factory.CloseDialog(failed));
|
||||
Assert.Equal("later", MessageFromAnyDialog(root.Modal!));
|
||||
Assert.True(factory.CloseDialog(later));
|
||||
}
|
||||
|
||||
private static RetailDialogFactory CreateFactory(
|
||||
UiRoot root,
|
||||
List<ImportedLayout> layouts)
|
||||
|
|
@ -464,6 +566,15 @@ public sealed class RetailDialogFactoryTests
|
|||
=> string.Join(" ", Assert.IsType<UiText>(layout.FindElement(
|
||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
|
||||
|
||||
private static string MessageFromAnyDialog(UiElement root)
|
||||
=> string.Join(
|
||||
" ",
|
||||
Assert.IsType<UiText>(UiElement.FindDescendant(
|
||||
root,
|
||||
RetailConfirmationDialogView.MessageElementId))
|
||||
.LinesProvider()
|
||||
.Select(static line => line.Text));
|
||||
|
||||
internal static ImportedLayout BuildDialogLayout(RetailDialogType type)
|
||||
{
|
||||
uint rootId = RetailDialogFactory.RootElementId(type);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue