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.
|
those four button pointers, the selected row/guid, and four dialog contexts.
|
||||||
It declares no viewport, `gmCG3DView`, or preview owner.
|
It declares no viewport, `gmCG3DView`, or preview owner.
|
||||||
- `RebuildCharacterList` (`0x004EC3A0`) creates each row through
|
- `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,
|
deletion in red, sorts by ordinal name, moves greyed entries to the tail,
|
||||||
and restores/falls back selection. LA8 preserves the already canonical LA7b
|
and restores/falls back selection. LA8 preserves the already canonical LA7b
|
||||||
display order and identity instead of sorting an App copy.
|
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`
|
`%USERPROFILE%/Documents/Asheron's Call` DAT set unless `ACDREAM_DAT_DIR`
|
||||||
overrides the location. It uses production `DatCollection`,
|
overrides the location. It uses production `DatCollection`,
|
||||||
`RetailDataIdResolver`, and `LayoutImporter`; it does not write the DATs.
|
`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:
|
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
|
order, projects red pending-delete rows and the exact button matrix, and opens
|
||||||
the shared retail dialogs. Delete wait survives the opcode-only acknowledgement
|
the shared retail dialogs. Delete wait survives the opcode-only acknowledgement
|
||||||
until the fresh roster arrives. Restore is fire-and-observe: a silent ACE
|
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
|
no-reply ends only when Runtime expires its correlation; retail's Please Wait
|
||||||
opens before the existing synchronous Enter command; error, reset, reconnect,
|
opens before the synchronous restore command and closes immediately if that
|
||||||
missing/displaced adapter, and disposal close owned contexts without re-entrant
|
command rejects or throws. Entering-world wait opens before the existing
|
||||||
commands. A failed transient row-template import leaves the Runtime revision
|
synchronous Enter command; error, reset, reconnect, missing/displaced adapter,
|
||||||
unconsumed and retries on the next frame.
|
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
|
There is deliberately no 3D preview and no claimed character-select background
|
||||||
scene. The screen root remains neutral with respect to render-loop 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 _enterWaitContext;
|
||||||
private uint _errorDialogContext;
|
private uint _errorDialogContext;
|
||||||
private bool _active;
|
private bool _active;
|
||||||
|
private bool _restoreCommandInFlight;
|
||||||
private bool _suppressDialogCallbacks;
|
private bool _suppressDialogCallbacks;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
|
@ -197,7 +198,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster))
|
if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster))
|
||||||
{
|
{
|
||||||
bool rowsReady;
|
bool rowsReady;
|
||||||
if (RowsMatchRoster(roster))
|
if (RowsMatchRoster(roster, snapshot.SlotCount))
|
||||||
{
|
{
|
||||||
ApplyHighlight(snapshot.HighlightedCharacterId);
|
ApplyHighlight(snapshot.HighlightedCharacterId);
|
||||||
rowsReady = true;
|
rowsReady = true;
|
||||||
|
|
@ -206,6 +207,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
{
|
{
|
||||||
rowsReady = RebuildRows(
|
rowsReady = RebuildRows(
|
||||||
roster,
|
roster,
|
||||||
|
snapshot.SlotCount,
|
||||||
snapshot.HighlightedCharacterId);
|
snapshot.HighlightedCharacterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,11 +282,16 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool RowsMatchRoster(
|
private bool RowsMatchRoster(
|
||||||
IReadOnlyList<RuntimeCharacterSelectionEntry> roster)
|
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
|
||||||
|
int allowedSlotCount)
|
||||||
{
|
{
|
||||||
if (_rows.Count != roster.Count)
|
if (_rows.Count != roster.Count)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
int rowHeight = ComputeRowHeight(
|
||||||
|
_list.Height,
|
||||||
|
roster.Count,
|
||||||
|
allowedSlotCount);
|
||||||
for (int i = 0; i < roster.Count; i++)
|
for (int i = 0; i < roster.Count; i++)
|
||||||
{
|
{
|
||||||
UiButton row = _rows[i];
|
UiButton row = _rows[i];
|
||||||
|
|
@ -292,6 +299,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
if (!_rowIds.TryGetValue(row, out uint characterId)
|
if (!_rowIds.TryGetValue(row, out uint characterId)
|
||||||
|| characterId != character.CharacterId
|
|| characterId != character.CharacterId
|
||||||
|| !string.Equals(row.Label, character.Name, StringComparison.Ordinal)
|
|| !string.Equals(row.Label, character.Name, StringComparison.Ordinal)
|
||||||
|
|| (int)row.Height != rowHeight
|
||||||
|| row.LabelColor != (character.IsPendingDelete
|
|| row.LabelColor != (character.IsPendingDelete
|
||||||
? new Vector4(1f, 0f, 0f, 1f)
|
? new Vector4(1f, 0f, 0f, 1f)
|
||||||
: Vector4.One))
|
: Vector4.One))
|
||||||
|
|
@ -305,6 +313,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
|
|
||||||
private bool RebuildRows(
|
private bool RebuildRows(
|
||||||
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
|
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
|
||||||
|
int allowedSlotCount,
|
||||||
uint highlightedCharacterId)
|
uint highlightedCharacterId)
|
||||||
{
|
{
|
||||||
foreach (UiButton row in _rows)
|
foreach (UiButton row in _rows)
|
||||||
|
|
@ -316,15 +325,34 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
_rowIds.Clear();
|
_rowIds.Clear();
|
||||||
_list.Flush();
|
_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)
|
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;
|
complete = false;
|
||||||
break;
|
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;
|
uint characterId = character.CharacterId;
|
||||||
row.Label = character.Name;
|
row.Label = character.Name;
|
||||||
row.LabelColor = character.IsPendingDelete
|
row.LabelColor = character.IsPendingDelete
|
||||||
|
|
@ -354,6 +382,21 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
return false;
|
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)
|
private void ApplyHighlight(uint highlightedCharacterId)
|
||||||
{
|
{
|
||||||
foreach (UiButton row in _rows)
|
foreach (UiButton row in _rows)
|
||||||
|
|
@ -407,9 +450,39 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
return;
|
return;
|
||||||
RuntimeCommandResult result = _bindings.Restore();
|
|
||||||
if (result.Accepted)
|
// ListenToElementMessage @ 0x004ED5A0 opens Please Wait before it
|
||||||
EnsureOperationWait();
|
// 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();
|
InvalidateAndTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -446,7 +519,8 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
CloseContext(ref _deleteDialogContext, suppressCallback: true);
|
CloseContext(ref _deleteDialogContext, suppressCallback: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
|
if (_restoreCommandInFlight
|
||||||
|
|| snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
|
||||||
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
||||||
or RuntimeCharacterSelectionOperation.RestoreRequested)
|
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> _activeQueued = new();
|
||||||
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
|
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
|
||||||
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
|
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
|
||||||
|
private readonly LinkedList<DialogInfo> _retryable = new();
|
||||||
private readonly List<DialogInfo> _openOrder = new();
|
private readonly List<DialogInfo> _openOrder = new();
|
||||||
private uint _globalContext;
|
private uint _globalContext;
|
||||||
private bool _resetting;
|
private bool _resetting;
|
||||||
|
|
@ -51,6 +52,8 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
|
|
||||||
public int PendingCount => _pending.Values.Sum(static queue => queue.Count);
|
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>
|
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
|
||||||
public static uint RootElementId(RetailDialogType type)
|
public static uint RootElementId(RetailDialogType type)
|
||||||
=> type switch
|
=> type switch
|
||||||
|
|
@ -93,14 +96,28 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
if (queueKey == NonQueuedKey)
|
if (queueKey == NonQueuedKey)
|
||||||
{
|
{
|
||||||
_activeNonQueued.Add(context, info);
|
_activeNonQueued.Add(context, info);
|
||||||
CreateDialog(info);
|
if (!TryCreateDialog(info))
|
||||||
|
{
|
||||||
|
_activeNonQueued.Remove(context);
|
||||||
|
QueueRetry(info);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
|
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
|
||||||
{
|
{
|
||||||
|
if (HasRetry(queueKey))
|
||||||
|
{
|
||||||
|
PendingQueue(queueKey).AddLast(info);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
_activeQueued.Add(queueKey, info);
|
_activeQueued.Add(queueKey, info);
|
||||||
CreateDialog(info);
|
if (!TryCreateDialog(info))
|
||||||
|
{
|
||||||
|
_activeQueued.Remove(queueKey);
|
||||||
|
QueueRetry(info);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +135,15 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
Suspend(current);
|
Suspend(current);
|
||||||
queue.AddFirst(current);
|
queue.AddFirst(current);
|
||||||
_activeQueued[queueKey] = info;
|
_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;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,11 +236,25 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Tick()
|
public void Tick()
|
||||||
{
|
{
|
||||||
|
RetryFailedDialogs();
|
||||||
foreach (DialogInfo info in _openOrder.ToArray())
|
foreach (DialogInfo info in _openOrder.ToArray())
|
||||||
info.View?.Tick();
|
info.View?.Tick();
|
||||||
}
|
}
|
||||||
|
|
@ -238,6 +277,7 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
DialogInfo[] infos = _activeNonQueued.Values
|
DialogInfo[] infos = _activeNonQueued.Values
|
||||||
.Concat(_activeQueued.Values)
|
.Concat(_activeQueued.Values)
|
||||||
.Concat(_pending.Values.SelectMany(static queue => queue))
|
.Concat(_pending.Values.SelectMany(static queue => queue))
|
||||||
|
.Concat(_retryable)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToArray();
|
.ToArray();
|
||||||
if (infos.Length == 0)
|
if (infos.Length == 0)
|
||||||
|
|
@ -249,6 +289,7 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
_activeNonQueued.Clear();
|
_activeNonQueued.Clear();
|
||||||
_activeQueued.Clear();
|
_activeQueued.Clear();
|
||||||
_pending.Clear();
|
_pending.Clear();
|
||||||
|
_retryable.Clear();
|
||||||
foreach (DialogInfo info in infos)
|
foreach (DialogInfo info in infos)
|
||||||
{
|
{
|
||||||
try { DialogDone(info); }
|
try { DialogDone(info); }
|
||||||
|
|
@ -292,41 +333,57 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
return queue;
|
return queue;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CreateDialog(DialogInfo info)
|
private bool TryCreateDialog(DialogInfo info)
|
||||||
{
|
{
|
||||||
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
|
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(
|
||||||
if (type is not (RetailDialogType.Confirmation
|
RetailDialogProperty.Type);
|
||||||
or RetailDialogType.Wait
|
try
|
||||||
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),
|
if (type is not (RetailDialogType.Confirmation
|
||||||
RetailDialogType.Message => new RetailMessageDialogView(
|
or RetailDialogType.Wait
|
||||||
_host, layout, info.Data, info.Context,
|
or RetailDialogType.Message
|
||||||
context => CloseDialog(context)),
|
or RetailDialogType.ConfirmationTextInput))
|
||||||
RetailDialogType.ConfirmationTextInput =>
|
{
|
||||||
new RetailConfirmationTextInputDialogView(
|
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,
|
_host, layout, info.Data, info.Context,
|
||||||
context => CloseDialog(context)),
|
context => CloseDialog(context)),
|
||||||
_ => new RetailConfirmationDialogView(
|
RetailDialogType.ConfirmationTextInput =>
|
||||||
_host, layout, info.Data, info.Context,
|
new RetailConfirmationTextInputDialogView(
|
||||||
context => CloseDialog(context)),
|
_host, layout, info.Data, info.Context,
|
||||||
};
|
context => CloseDialog(context)),
|
||||||
info.View = view;
|
_ => new RetailConfirmationDialogView(
|
||||||
_host.AddChild(view.Root);
|
_host, layout, info.Data, info.Context,
|
||||||
_host.BringToFront(view.Root);
|
context => CloseDialog(context)),
|
||||||
_openOrder.Add(info);
|
};
|
||||||
_host.Modal = view.Root;
|
info.View = view;
|
||||||
view.Tick();
|
_host.AddChild(view.Root);
|
||||||
UpdatePendingDialogDisplays();
|
_host.BringToFront(view.Root);
|
||||||
DialogOpened?.Invoke(info.Context);
|
_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)
|
private void Suspend(DialogInfo info)
|
||||||
|
|
@ -380,6 +437,9 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
if (_activeQueued.ContainsKey(queueKey))
|
if (_activeQueued.ContainsKey(queueKey))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (TryActivateRetry(queueKey))
|
||||||
|
return;
|
||||||
|
|
||||||
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|
||||||
|| queue.First is null)
|
|| queue.First is null)
|
||||||
return;
|
return;
|
||||||
|
|
@ -389,7 +449,73 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
if (queue.Count == 0)
|
if (queue.Count == 0)
|
||||||
_pending.Remove(queueKey);
|
_pending.Remove(queueKey);
|
||||||
_activeQueued.Add(queueKey, next);
|
_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()
|
private void UpdatePendingDialogDisplays()
|
||||||
|
|
|
||||||
|
|
@ -434,6 +434,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
||||||
private ItemCooldownUiController? _itemCooldownController;
|
private ItemCooldownUiController? _itemCooldownController;
|
||||||
private VividTargetIndicatorController? _vividTargetIndicator;
|
private VividTargetIndicatorController? _vividTargetIndicator;
|
||||||
|
private CharacterManagementUiMountCoordinator? _characterManagementMount;
|
||||||
private IDisposable? _characterSheetSubscription;
|
private IDisposable? _characterSheetSubscription;
|
||||||
private ResourceShutdownTransaction? _shutdown;
|
private ResourceShutdownTransaction? _shutdown;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
@ -500,7 +501,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
MountVendor();
|
MountVendor();
|
||||||
MountSecureTrade();
|
MountSecureTrade();
|
||||||
MountItemCooldowns();
|
MountItemCooldowns();
|
||||||
MountCharacterManagement();
|
ConfigureCharacterManagement();
|
||||||
|
_characterManagementMount?.Tick();
|
||||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||||
BindToolbarPanelButtons();
|
BindToolbarPanelButtons();
|
||||||
SyncToolbarWindowButtons();
|
SyncToolbarWindowButtons();
|
||||||
|
|
@ -595,7 +597,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
public VendorUiController? VendorController { get; private set; }
|
public VendorUiController? VendorController { get; private set; }
|
||||||
public OptionsPanelController? OptionsPanelController { get; private set; }
|
public OptionsPanelController? OptionsPanelController { get; private set; }
|
||||||
public SocialPanelController? SocialPanelController { get; private set; }
|
public SocialPanelController? SocialPanelController { get; private set; }
|
||||||
internal CharacterManagementUiController? CharacterManagementController { get; private set; }
|
internal CharacterManagementUiController? CharacterManagementController =>
|
||||||
|
_characterManagementMount?.Controller;
|
||||||
|
|
||||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||||
{
|
{
|
||||||
|
|
@ -641,6 +644,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
ExternalContainerController?.Tick();
|
ExternalContainerController?.Tick();
|
||||||
SocialPanelController?.Tick();
|
SocialPanelController?.Tick();
|
||||||
_itemCooldownController?.Tick();
|
_itemCooldownController?.Tick();
|
||||||
|
_characterManagementMount?.Tick();
|
||||||
CharacterManagementController?.Tick();
|
CharacterManagementController?.Tick();
|
||||||
DialogFactory?.Tick();
|
DialogFactory?.Tick();
|
||||||
Host.Tick(deltaSeconds);
|
Host.Tick(deltaSeconds);
|
||||||
|
|
@ -3051,13 +3055,29 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
|
|
||||||
private void MountDialogFactory()
|
private void MountDialogFactory()
|
||||||
{
|
{
|
||||||
|
if (DialogFactory is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
uint layoutId;
|
uint layoutId;
|
||||||
lock (_bindings.Assets.DatLock)
|
try
|
||||||
{
|
{
|
||||||
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
|
lock (_bindings.Assets.DatLock)
|
||||||
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
|
{
|
||||||
// creates a fresh type-specific root from that catalog.
|
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
|
||||||
layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u);
|
// 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)
|
if (layoutId == 0u)
|
||||||
|
|
@ -3690,19 +3710,28 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
|
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MountCharacterManagement()
|
private void ConfigureCharacterManagement()
|
||||||
{
|
{
|
||||||
CharacterSelectionRuntimeBindings? bindings =
|
CharacterSelectionRuntimeBindings? bindings =
|
||||||
_bindings.CharacterSelection;
|
_bindings.CharacterSelection;
|
||||||
if (bindings is null)
|
if (bindings is null || _characterManagementMount is not null)
|
||||||
return;
|
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;
|
const uint stringTableId = 0x23000002u;
|
||||||
uint layoutId;
|
uint layoutId;
|
||||||
ImportedLayout? layout;
|
ImportedLayout? layout;
|
||||||
|
|
@ -3730,7 +3759,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
"[UI] character management: enum-table-5 root could not be imported.");
|
"[UI] character management: enum-table-5 root could not be imported.");
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
string? deleteResponse;
|
string? deleteResponse;
|
||||||
|
|
@ -3767,7 +3796,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
"[UI] character management: required retail strings are unavailable.");
|
"[UI] character management: required retail strings are unavailable.");
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
|
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
|
||||||
|
|
@ -3798,23 +3827,15 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CharacterManagementController = CharacterManagementUiController.Bind(
|
return new CharacterManagementUiMountResources(
|
||||||
Host.Root,
|
layoutId,
|
||||||
layout,
|
layout,
|
||||||
ResolveTemplate,
|
ResolveTemplate,
|
||||||
DialogFactory,
|
|
||||||
bindings,
|
|
||||||
new CharacterManagementUiController.DialogStrings(
|
new CharacterManagementUiController.DialogStrings(
|
||||||
ComposeDeleteConfirmation,
|
ComposeDeleteConfirmation,
|
||||||
deleteResponse,
|
deleteResponse,
|
||||||
pleaseWait,
|
pleaseWait,
|
||||||
enteringWorld));
|
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(
|
private static string? ResolveCharacterManagementString(
|
||||||
|
|
@ -3873,7 +3894,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
() => _itemConfirmationController?.Dispose(),
|
() => _itemConfirmationController?.Dispose(),
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
CharacterManagementController?.Dispose();
|
_characterManagementMount?.Dispose();
|
||||||
_gameplayConfirmationController?.Dispose();
|
_gameplayConfirmationController?.Dispose();
|
||||||
},
|
},
|
||||||
() => DialogFactory?.Dispose(),
|
() => DialogFactory?.Dispose(),
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,9 @@ namespace AcDream.App.Tests.UI.Layout;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CharacterManagementLiveDatTests
|
public sealed class CharacterManagementLiveDatTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[InstalledDatFact]
|
||||||
public void EnumTable5_ResolvesAndImportsTheExactRetailScreenAndDialogs()
|
public void EnumTable5_ResolvesAndImportsTheExactRetailScreenAndDialogs()
|
||||||
{
|
{
|
||||||
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
|
|
||||||
return;
|
|
||||||
|
|
||||||
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
?? Path.Combine(
|
?? Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
|
@ -162,3 +159,23 @@ public sealed class CharacterManagementLiveDatTests
|
||||||
yield return descendant;
|
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(
|
Assert.Equal(
|
||||||
["Alpha", "Zulu", "Aaron (pending)"],
|
["Alpha", "Zulu", "Aaron (pending)"],
|
||||||
controller.Rows.Select(static row => row.Label!).ToArray());
|
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.True(controller.Rows[0].Selected);
|
||||||
Assert.False(controller.Rows[1].Selected);
|
Assert.False(controller.Rows[1].Selected);
|
||||||
Assert.Equal(Vector4.One, controller.Rows[0].LabelColor);
|
Assert.Equal(Vector4.One, controller.Rows[0].LabelColor);
|
||||||
|
|
@ -65,6 +68,126 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
Assert.True(restore.Enabled);
|
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]
|
[Fact]
|
||||||
public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster()
|
public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster()
|
||||||
{
|
{
|
||||||
|
|
@ -218,6 +341,78 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
Assert.False(environment.Dialogs.IsOpen);
|
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]
|
[Fact]
|
||||||
public void MissingOrDisposedBorrowedView_ClosesDialogsFlushesRowsAndDisposesSafely()
|
public void MissingOrDisposedBorrowedView_ClosesDialogsFlushesRowsAndDisposesSafely()
|
||||||
{
|
{
|
||||||
|
|
@ -475,6 +670,12 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
public int EnterCalls { get; private set; }
|
public int EnterCalls { get; private set; }
|
||||||
public int ConfirmDeleteCalls { get; private set; }
|
public int ConfirmDeleteCalls { get; private set; }
|
||||||
public int CancelCalls { 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)
|
public void SetOperation(RuntimeCharacterSelectionOperation operation)
|
||||||
{
|
{
|
||||||
|
|
@ -578,7 +779,13 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
|
|
||||||
private RuntimeCommandResult Restore()
|
private RuntimeCommandResult Restore()
|
||||||
{
|
{
|
||||||
|
RestoreCalls++;
|
||||||
uint id = View.Snapshot.HighlightedCharacterId;
|
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
|
Update(snapshot => snapshot with
|
||||||
{
|
{
|
||||||
LastRestoreRequestedCharacterId = id,
|
LastRestoreRequestedCharacterId = id,
|
||||||
|
|
@ -590,6 +797,7 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
false,
|
false,
|
||||||
true),
|
true),
|
||||||
});
|
});
|
||||||
|
AfterRestoreProjection?.Invoke();
|
||||||
return Result(RuntimeCommandStatus.Accepted, id);
|
return Result(RuntimeCommandStatus.Accepted, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -440,6 +440,108 @@ public sealed class RetailDialogFactoryTests
|
||||||
Assert.Null(root.KeyboardFocus);
|
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(
|
private static RetailDialogFactory CreateFactory(
|
||||||
UiRoot root,
|
UiRoot root,
|
||||||
List<ImportedLayout> layouts)
|
List<ImportedLayout> layouts)
|
||||||
|
|
@ -464,6 +566,15 @@ public sealed class RetailDialogFactoryTests
|
||||||
=> string.Join(" ", Assert.IsType<UiText>(layout.FindElement(
|
=> string.Join(" ", Assert.IsType<UiText>(layout.FindElement(
|
||||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
|
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
|
||||||
|
|
||||||
|
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)
|
internal static ImportedLayout BuildDialogLayout(RetailDialogType type)
|
||||||
{
|
{
|
||||||
uint rootId = RetailDialogFactory.RootElementId(type);
|
uint rootId = RetailDialogFactory.RootElementId(type);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue