Make character UI retries transactional

This commit is contained in:
Erik 2026-08-14 21:14:08 +02:00
parent aeac874dab
commit 1dd5706e15
6 changed files with 352 additions and 42 deletions

View file

@ -104,7 +104,12 @@ retries on the next frame. Initial dialog-catalog, character root, and string
misses likewise retry on later ticks without mounting a duplicate root or misses likewise retry on later ticks without mounting a duplicate root or
controller. Dialog presenter/catalog failures move their contexts to an controller. Dialog presenter/catalog failures move their contexts to an
internal retry ledger, so UI callbacks do not retain poisoned active/queued internal retry ledger, so UI callbacks do not retain poisoned active/queued
entries and the same context can appear after resource recovery. entries and the same context can appear after resource recovery. Priority
contexts remain ahead of ordinary retries and preserve retail's nested
preemption order when creation recovers. The mount coordinator owns a detached
controller before attaching its root or running the first template-resolving
tick; any partial failure disposes that exact controller before retry, so roots
and handlers cannot accumulate.
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

View file

@ -116,6 +116,36 @@ internal sealed class CharacterManagementUiController : IDisposable
RetailDialogFactory dialogs, RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings, CharacterSelectionRuntimeBindings bindings,
DialogStrings strings) DialogStrings strings)
{
CharacterManagementUiController? controller = CreateDetached(
host,
layout,
templateResolver,
dialogs,
bindings,
strings);
if (controller is null)
return null;
try
{
controller.AttachAndTick();
return controller;
}
catch
{
controller.Dispose();
throw;
}
}
internal static CharacterManagementUiController? CreateDetached(
UiRoot host,
ImportedLayout layout,
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
{ {
ArgumentNullException.ThrowIfNull(host); ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(layout);
@ -144,20 +174,37 @@ internal sealed class CharacterManagementUiController : IDisposable
} }
list.TemplateResolver = templateResolver; list.TemplateResolver = templateResolver;
var controller = new CharacterManagementUiController( try
host, {
layout, return new CharacterManagementUiController(
list, host,
create, layout,
enter, list,
delete, create,
restore, enter,
dialogs, delete,
bindings, restore,
strings); dialogs,
host.AddChild(controller.Root); bindings,
controller.Tick(); strings);
return controller; }
catch
{
list.TemplateResolver = null;
create.OnClick = null;
enter.OnClick = null;
delete.OnClick = null;
restore.OnClick = null;
throw;
}
}
internal void AttachAndTick()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (Root.Parent is null)
_host.AddChild(Root);
Tick();
} }
private static bool ContainsViewport(UiElement element) private static bool ContainsViewport(UiElement element)
@ -247,20 +294,26 @@ internal sealed class CharacterManagementUiController : IDisposable
if (_disposed) if (_disposed)
return; return;
_disposed = true; _disposed = true;
CloseAllDialogs(suppressCallbacks: true); try
_enter.OnClick = null;
_delete.OnClick = null;
_restore.OnClick = null;
foreach (UiButton row in _rows)
{ {
row.OnClick = null; CloseAllDialogs(suppressCallbacks: true);
row.OnDoubleClick = null; }
finally
{
_enter.OnClick = null;
_delete.OnClick = null;
_restore.OnClick = null;
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowIds.Clear();
_list.Flush();
_list.TemplateResolver = null;
_host.RemoveChild(Root);
} }
_rows.Clear();
_rowIds.Clear();
_list.Flush();
_list.TemplateResolver = null;
_host.RemoveChild(Root);
} }
private static bool TryCaptureRoster( private static bool TryCaptureRoster(

View file

@ -53,23 +53,42 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable
if (resources is null) if (resources is null)
return; return;
Controller = CharacterManagementUiController.Bind( CharacterManagementUiController? candidate =
CharacterManagementUiController.CreateDetached(
_host, _host,
resources.Layout, resources.Layout,
resources.TemplateResolver, resources.TemplateResolver,
dialogs, dialogs,
_bindings, _bindings,
resources.Strings); resources.Strings);
if (Controller is not null) if (candidate is null)
{ return;
Console.WriteLine(
$"[UI] retail character management from enum table 5 " // Take ownership before the first attach/tick. Template resolution
+ $"(0x10000005 -> 0x{resources.LayoutId:X8}, " // happens inside that tick and can throw after the root and button
+ "root 0x1000039A; flat list, no viewport)."); // handlers are live; the catch below can therefore always retire
} // the exact partial controller before a later retry.
Controller = candidate;
candidate.AttachAndTick();
Console.WriteLine(
$"[UI] retail character management from enum table 5 "
+ $"(0x10000005 -> 0x{resources.LayoutId:X8}, "
+ "root 0x1000039A; flat list, no viewport).");
} }
catch (Exception error) catch (Exception error)
{ {
CharacterManagementUiController? partial = Controller;
Controller = null;
try
{
partial?.Dispose();
}
catch (Exception cleanupError)
{
Console.WriteLine(
"[UI] character management partial-mount cleanup failed: "
+ cleanupError.Message);
}
Console.WriteLine( Console.WriteLine(
"[UI] character management mount will retry after resource " "[UI] character management mount will retry after resource "
+ $"recovery: {error.Message}"); + $"recovery: {error.Message}");

View file

@ -15,6 +15,7 @@ public sealed class RetailDialogFactory : IDisposable
public required RetailDialogData Data { get; init; } public required RetailDialogData Data { get; init; }
public required uint Context { get; init; } public required uint Context { get; init; }
public required uint QueueKey { get; init; } public required uint QueueKey { get; init; }
public required ulong Sequence { get; init; }
public Action<RetailDialogData>? Callback { get; init; } public Action<RetailDialogData>? Callback { get; init; }
public IRetailDialogView? View { get; set; } public IRetailDialogView? View { get; set; }
} }
@ -27,6 +28,7 @@ public sealed class RetailDialogFactory : IDisposable
private readonly LinkedList<DialogInfo> _retryable = 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 ulong _globalSequence;
private bool _resetting; private bool _resetting;
private bool _disposed; private bool _disposed;
@ -90,6 +92,7 @@ public sealed class RetailDialogFactory : IDisposable
Data = ownedData, Data = ownedData,
Context = context, Context = context,
QueueKey = queueKey, QueueKey = queueKey,
Sequence = NextSequence(),
Callback = callback, Callback = callback,
}; };
@ -106,7 +109,7 @@ public sealed class RetailDialogFactory : IDisposable
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current)) if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
{ {
if (HasRetry(queueKey)) if (HasRetry(queueKey) && !IsPriority(info))
{ {
PendingQueue(queueKey).AddLast(info); PendingQueue(queueKey).AddLast(info);
return context; return context;
@ -122,7 +125,7 @@ public sealed class RetailDialogFactory : IDisposable
} }
LinkedList<DialogInfo> queue = PendingQueue(queueKey); LinkedList<DialogInfo> queue = PendingQueue(queueKey);
if (!ownedData.GetBoolean(RetailDialogProperty.Priority)) if (!IsPriority(info))
{ {
queue.AddLast(info); queue.AddLast(info);
UpdatePendingDialogDisplays(); UpdatePendingDialogDisplays();
@ -324,6 +327,14 @@ public sealed class RetailDialogFactory : IDisposable
return _globalContext; return _globalContext;
} }
private ulong NextSequence()
{
_globalSequence++;
if (_globalSequence == 0uL)
_globalSequence++;
return _globalSequence;
}
private LinkedList<DialogInfo> PendingQueue(uint queueKey) private LinkedList<DialogInfo> PendingQueue(uint queueKey)
{ {
if (_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)) if (_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue))
@ -480,14 +491,37 @@ public sealed class RetailDialogFactory : IDisposable
continue; continue;
} }
if (!_activeQueued.ContainsKey(info.QueueKey) if (!ReferenceEquals(FirstRetry(info.QueueKey), info))
&& ReferenceEquals(FirstRetry(info.QueueKey), info)) continue;
{
if (!_activeQueued.TryGetValue(
info.QueueKey,
out DialogInfo? active))
TryActivateRetry(info.QueueKey); TryActivateRetry(info.QueueKey);
} else if (IsPriority(info)
&& (!IsPriority(active) || info.Sequence > active.Sequence))
TryPreemptWithRetry(info, active);
} }
} }
private void TryPreemptWithRetry(DialogInfo priority, DialogInfo current)
{
LinkedList<DialogInfo> queue = PendingQueue(priority.QueueKey);
Suspend(current);
queue.AddFirst(current);
_activeQueued[priority.QueueKey] = priority;
_retryable.Remove(priority);
if (TryCreateDialog(priority))
return;
_activeQueued.Remove(priority.QueueKey);
queue.Remove(current);
if (queue.Count == 0)
_pending.Remove(priority.QueueKey);
OpenSpecificDialog(current);
QueueRetry(priority);
}
private bool TryActivateRetry(uint queueKey) private bool TryActivateRetry(uint queueKey)
{ {
DialogInfo? info = FirstRetry(queueKey); DialogInfo? info = FirstRetry(queueKey);
@ -512,10 +546,29 @@ public sealed class RetailDialogFactory : IDisposable
private bool HasRetry(uint queueKey) => FirstRetry(queueKey) is not null; private bool HasRetry(uint queueKey) => FirstRetry(queueKey) is not null;
private static bool IsPriority(DialogInfo info) =>
info.Data.GetBoolean(RetailDialogProperty.Priority);
private void QueueRetry(DialogInfo info) private void QueueRetry(DialogInfo info)
{ {
if (!_retryable.Contains(info)) if (_retryable.Contains(info))
return;
if (!IsPriority(info))
{
_retryable.AddLast(info); _retryable.AddLast(info);
return;
}
LinkedListNode<DialogInfo>? existing = _retryable.First;
while (existing is not null
&& existing.Value.QueueKey != info.QueueKey)
{
existing = existing.Next;
}
if (existing is null)
_retryable.AddLast(info);
else
_retryable.AddBefore(existing, info);
} }
private void UpdatePendingDialogDisplays() private void UpdatePendingDialogDisplays()

View file

@ -188,6 +188,66 @@ public sealed class CharacterManagementUiControllerTests
Assert.Equal(3, resourceAttempts); Assert.Equal(3, resourceAttempts);
} }
[Fact]
public void MountCoordinator_PostAttachFailuresDisposeBeforeRetryAndRecovery()
{
var host = new UiRoot { Width = 800f, Height = 600f };
var runtime = new FakeRuntime();
using var dialogs = new RetailDialogFactory(
host,
RetailDialogFactoryTests.BuildDialogLayout);
var screens = new List<ImportedLayout>();
int failingAttempts = 2;
using var coordinator = new CharacterManagementUiMountCoordinator(
host,
runtime.Bindings,
() => dialogs,
() =>
{
ImportedLayout screen = BuildScreen();
screens.Add(screen);
bool throwAfterAttach = failingAttempts-- > 0;
return new CharacterManagementUiMountResources(
0x21000004u,
screen,
(_, _) => throwAfterAttach
? throw new InvalidOperationException(
"template failed after root attach")
: BuildRow(),
TestStrings());
});
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Empty(host.Children);
Assert.Single(screens);
AssertDetachedAndUnbound(screens[0]);
coordinator.Tick();
Assert.Null(coordinator.Controller);
Assert.Empty(host.Children);
Assert.Equal(2, screens.Count);
Assert.All(screens, AssertDetachedAndUnbound);
coordinator.Tick();
CharacterManagementUiController mounted = Assert.IsType<
CharacterManagementUiController>(coordinator.Controller);
Assert.Equal(3, screens.Count);
Assert.Single(host.Children);
Assert.Same(mounted.Root, host.Children[0]);
Assert.NotNull(Assert.IsType<UiButton>(screens[2].FindElement(
CharacterManagementUiController.EnterElementId)).OnClick);
coordinator.Tick();
Assert.Equal(3, screens.Count);
Assert.Single(host.Children);
coordinator.Dispose();
Assert.Empty(host.Children);
Assert.Null(coordinator.Controller);
Assert.All(screens, AssertDetachedAndUnbound);
}
[Fact] [Fact]
public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster() public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster()
{ {
@ -509,6 +569,17 @@ public sealed class CharacterManagementUiControllerTests
"Please Wait", "Please Wait",
"Entering World"); "Entering World");
private static void AssertDetachedAndUnbound(ImportedLayout screen)
{
Assert.Null(screen.Root.Parent);
Assert.Null(Assert.IsType<UiButton>(screen.FindElement(
CharacterManagementUiController.EnterElementId)).OnClick);
Assert.Null(Assert.IsType<UiButton>(screen.FindElement(
CharacterManagementUiController.DeleteElementId)).OnClick);
Assert.Null(Assert.IsType<UiButton>(screen.FindElement(
CharacterManagementUiController.RestoreElementId)).OnClick);
}
private static ImportedLayout BuildScreen(bool includePreview = false) private static ImportedLayout BuildScreen(bool includePreview = false)
{ {
var root = new ElementInfo var root = new ElementInfo

View file

@ -542,6 +542,110 @@ public sealed class RetailDialogFactoryTests
Assert.True(factory.CloseDialog(later)); Assert.True(factory.CloseDialog(later));
} }
[Fact]
public void PriorityRequest_PreemptsOrdinaryRetryAndPreservesOrdinaryFifo()
{
var root = new UiRoot { Width = 800f, Height = 600f };
bool waitsAvailable = false;
using var factory = new RetailDialogFactory(root, type =>
type == RetailDialogType.Wait && !waitsAvailable
? null
: BuildDialogLayout(type));
uint failed = factory.MakeWait("failed ordinary");
uint later = factory.MakeWait("later ordinary");
uint priority = factory.MakeDialog(
Priority(RetailDialogData.Message("priority")));
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(1, factory.RetryCount);
Assert.Equal(1, factory.PendingCount);
Assert.Equal("priority", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(priority));
Assert.Equal(0, factory.ActiveCount);
Assert.Equal(1, factory.RetryCount);
Assert.Equal(1, factory.PendingCount);
waitsAvailable = true;
factory.Tick();
Assert.Equal("failed ordinary", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(failed));
Assert.Equal("later ordinary", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(later));
}
[Fact]
public void FailedPriority_RetriesAheadOfRestoredActiveAndQueuedDialog()
{
var root = new UiRoot { Width = 800f, Height = 600f };
bool priorityAvailable = false;
using var factory = new RetailDialogFactory(root, type =>
type == RetailDialogType.Message && !priorityAvailable
? null
: BuildDialogLayout(type));
uint active = factory.MakeWait("active");
uint queued = factory.MakeWait("queued");
uint priority = factory.MakeDialog(
Priority(RetailDialogData.Message("priority")));
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(1, factory.RetryCount);
Assert.Equal(1, factory.PendingCount);
Assert.Equal("active", MessageFromAnyDialog(root.Modal!));
priorityAvailable = true;
factory.Tick();
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(0, factory.RetryCount);
Assert.Equal(2, factory.PendingCount);
Assert.Equal("priority", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(priority));
Assert.Equal("active", MessageFromAnyDialog(root.Modal!));
Assert.Equal(1, factory.PendingCount);
Assert.True(factory.CloseDialog(active));
Assert.Equal("queued", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(queued));
}
[Fact]
public void MultipleRetries_NewestPriorityFirstThenOlderPriorityThenOrdinaryFifo()
{
var root = new UiRoot { Width = 800f, Height = 600f };
bool available = false;
using var factory = new RetailDialogFactory(root, type =>
!available ? null : BuildDialogLayout(type));
uint ordinary = factory.MakeWait("ordinary");
uint later = factory.MakeWait("later");
uint olderPriority = factory.MakeDialog(
Priority(RetailDialogData.Message("older priority")));
uint newerPriority = factory.MakeDialog(Priority(
RetailDialogData.ConfirmationTextInput("newer priority")));
Assert.Equal(0, factory.ActiveCount);
Assert.Equal(3, factory.RetryCount);
Assert.Equal(1, factory.PendingCount);
available = true;
factory.Tick();
Assert.Equal(1, factory.ActiveCount);
Assert.Equal(2, factory.RetryCount);
Assert.Equal("newer priority", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(newerPriority));
Assert.Equal("older priority", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(olderPriority));
Assert.Equal("ordinary", MessageFromAnyDialog(root.Modal!));
Assert.True(factory.CloseDialog(ordinary));
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)
@ -575,6 +679,11 @@ public sealed class RetailDialogFactoryTests
.LinesProvider() .LinesProvider()
.Select(static line => line.Text)); .Select(static line => line.Text));
private static RetailDialogData Priority(RetailDialogData data) =>
data.Set(RetailDialogProperty.Priority, true)
.Set(RetailDialogProperty.QueueKey,
RetailDialogFactory.DefaultQueueKey);
internal static ImportedLayout BuildDialogLayout(RetailDialogType type) internal static ImportedLayout BuildDialogLayout(RetailDialogType type)
{ {
uint rootId = RetailDialogFactory.RootElementId(type); uint rootId = RetailDialogFactory.RootElementId(type);