acdream/src/AcDream.App/UI/Layout/RetailDialogFactory.cs
Erik 5d247d5518 fix(chat): CH4 re-review fixes — dialog-queue reentrancy, settings option-bit chokepoint
Should-fix 1: RetailDialogFactory.CloseDialog's queued branch removed the
active DialogInfo, ran DialogDone (whose callback can synchronously open a
new dialog under the SAME queue key — the two-stage house-abandon
confirmation does exactly this), then called OpenNextDialog, which did an
unconditional Dictionary.Add on a key the reentrant dialog had already
re-occupied. Retail's HashTable::add tolerates the duplicate; Dictionary
throws. OpenNextDialog now returns early when the queue key is already
active — the reentrant dialog's own eventual close drains the queue.

Should-fix 2: @join/@leave wrote the local RuntimeCharacterOptionsState bit
before sending, but the Settings Chat toggles reached a second binding
(SendSingleCharacterOption) that only sent the wire message, leaving the
Turbine membership gate stale until the next PlayerDescription.
LiveSessionRuntimeFactory.CreateCommandBindings now has one shared local
function for both entrances.

Should-fix 3: corrected TS-68/#360 wording again — retail's DoAllegiance
dispatcher table EXECUTES boot/ban/officer/title/motd/name/lock/house/
chat/broadcast locally through their own handlers; acdream shows the
unrecognized-subcommand refusal for all nine pending the #360 port. What
matches retail is the ownership rule (the verb never reaches
DoChannelCommand/the server), not the subcommand behavior itself. Removed
the inaccurate "matching retail, not merely harmless" / "now matches
this" claims from both the register row and the issue.

Nits: corrected the HouseAbandonDialogCallback_First citation (0x00580E1A
is DoHouse's load site for the callback pointer, not the function entry —
the entry is 0x00580240, with the stage-2 confirmation string built at
0x005802D8) in both ClientCommandController.cs and the mirrored test
comment; added an InlineData case pinning "@clist allegiance" to
RequestChannelList(0x02000000); converted RetailClientCommandCatalog.
KnownVerbs from a plain array to a FrozenSet<string> with
StringComparer.OrdinalIgnoreCase, matching the file's other lookup tables.

Suite: 12,221 passed / 4 skipped / 0 failed (Release), up from CH4's
12,216/4/0 — net +5 tests, no removals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:24:43 +02:00

362 lines
12 KiB
C#

namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained-mode port of retail <c>DialogFactory @ 0x004773C0..0x00478470</c>.
/// It owns dialog contexts, independent FIFO queue groups, nonqueued dialogs,
/// priority preemption, callback delivery, close notices, and fresh catalog roots.
/// </summary>
public sealed class RetailDialogFactory : IDisposable
{
public const uint DefaultQueueKey = 2u;
public const uint NonQueuedKey = 1u;
private sealed class DialogInfo
{
public required RetailDialogData Data { get; init; }
public required uint Context { get; init; }
public required uint QueueKey { get; init; }
public Action<RetailDialogData>? Callback { get; init; }
public RetailConfirmationDialogView? View { get; set; }
}
private readonly UiRoot _host;
private readonly Func<RetailDialogType, ImportedLayout?> _createLayout;
private readonly Dictionary<uint, DialogInfo> _activeQueued = new();
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
private readonly List<DialogInfo> _openOrder = new();
private uint _globalContext;
private bool _resetting;
private bool _disposed;
public RetailDialogFactory(
UiRoot host,
Func<RetailDialogType, ImportedLayout?> createLayout)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout));
}
/// <summary>
/// Retail's global close-dialog notice, raised after the per-context callback and
/// before the live root is removed from its parent.
/// </summary>
public event Action<uint, RetailDialogData>? DialogClosed;
public event Action<uint>? DialogOpened;
public bool IsOpen => _activeQueued.Count != 0 || _activeNonQueued.Count != 0;
public int ActiveCount => _activeQueued.Count + _activeNonQueued.Count;
public int PendingCount => _pending.Values.Sum(static queue => queue.Count);
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
public static uint RootElementId(RetailDialogType type)
=> type switch
{
RetailDialogType.Confirmation => 0x15u,
RetailDialogType.Wait => 0x31u,
RetailDialogType.Message => 0x24u,
RetailDialogType.TextInput => 0x28u,
RetailDialogType.ConfirmationTextInput => 0x2Cu,
RetailDialogType.Menu => 0x1Bu,
RetailDialogType.ConfirmationMenu => 0x1Fu,
_ => 0u,
};
public uint MakeDialog(RetailDialogData data)
=> MakeDialog(data, callback: null);
/// <summary>
/// Retail <c>MakeCallbackDialogInCurrentUI @ 0x00478430</c>.
/// </summary>
public uint MakeDialog(RetailDialogData data, Action<RetailDialogData>? callback)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(data);
uint context = NextContext();
RetailDialogData ownedData = data.Clone();
uint queueKey = ownedData.GetUInt32(RetailDialogProperty.QueueKey, DefaultQueueKey);
if (queueKey == 0u)
queueKey = DefaultQueueKey;
var info = new DialogInfo
{
Data = ownedData,
Context = context,
QueueKey = queueKey,
Callback = callback,
};
if (queueKey == NonQueuedKey)
{
_activeNonQueued.Add(context, info);
CreateDialog(info);
return context;
}
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
{
_activeQueued.Add(queueKey, info);
CreateDialog(info);
return context;
}
LinkedList<DialogInfo> queue = PendingQueue(queueKey);
if (!ownedData.GetBoolean(RetailDialogProperty.Priority))
{
queue.AddLast(info);
UpdatePendingDialogDisplays();
return context;
}
// MakeDialog's 0x8D branch removes the active root without completing the
// DialogInfo, inserts that current info at the front, then displays the
// priority request in its place.
Suspend(current);
queue.AddFirst(current);
_activeQueued[queueKey] = info;
CreateDialog(info);
return context;
}
public uint MakeConfirmation(
string message,
Action<RetailDialogData>? callback = null,
uint queueKey = DefaultQueueKey,
bool priority = false)
{
RetailDialogData data = RetailDialogData.Confirmation(message)
.Set(RetailDialogProperty.QueueKey, queueKey);
if (priority)
data.Set(RetailDialogProperty.Priority, true);
return MakeDialog(data, callback);
}
/// <summary>
/// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active
/// nonqueued dialog, an active queued dialog, or an item still pending in a queue.
/// </summary>
public bool CloseDialog(uint context)
{
if (context == 0u)
return false;
if (_activeNonQueued.Remove(context, out DialogInfo? nonQueued))
{
DialogDone(nonQueued);
return true;
}
foreach ((uint queueKey, DialogInfo active) in _activeQueued.ToArray())
{
if (active.Context != context)
continue;
_activeQueued.Remove(queueKey);
DialogDone(active);
OpenNextDialog(queueKey);
return true;
}
foreach ((uint queueKey, LinkedList<DialogInfo> queue) in _pending.ToArray())
{
LinkedListNode<DialogInfo>? node = queue.First;
while (node is not null && node.Value.Context != context)
node = node.Next;
if (node is null)
continue;
DialogInfo pending = node.Value;
queue.Remove(node);
if (queue.Count == 0)
_pending.Remove(queueKey);
DialogDone(pending);
UpdatePendingDialogDisplays();
return true;
}
return false;
}
public void Tick()
{
foreach (DialogInfo info in _openOrder.ToArray())
info.View?.Tick();
}
/// <summary>
/// Retail <c>DialogFactory::Reset @ 0x00477950</c> completes active and pending
/// infos before clearing the factory.
/// </summary>
public void Reset()
{
if (_resetting)
return;
_resetting = true;
List<Exception>? failures = null;
try
{
while (true)
{
DialogInfo[] infos = _activeNonQueued.Values
.Concat(_activeQueued.Values)
.Concat(_pending.Values.SelectMany(static queue => queue))
.Distinct()
.ToArray();
if (infos.Length == 0)
break;
// Callbacks may synchronously create another dialog. Retire
// this exact snapshot, then loop until those reentrant infos
// have also completed through DialogDone.
_activeNonQueued.Clear();
_activeQueued.Clear();
_pending.Clear();
foreach (DialogInfo info in infos)
{
try { DialogDone(info); }
catch (Exception error) { (failures ??= []).Add(error); }
}
}
}
finally
{
_resetting = false;
RefreshModal();
}
if (failures is not null)
throw new AggregateException(
"One or more dialogs failed while the factory reset.",
failures);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Reset();
}
private uint NextContext()
{
_globalContext++;
if (_globalContext == 0u)
_globalContext++;
return _globalContext;
}
private LinkedList<DialogInfo> PendingQueue(uint queueKey)
{
if (_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue))
return queue;
queue = new LinkedList<DialogInfo>();
_pending.Add(queueKey, queue);
return queue;
}
private void CreateDialog(DialogInfo info)
{
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
if (type != RetailDialogType.Confirmation)
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}.");
var view = 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;
UpdatePendingDialogDisplays();
DialogOpened?.Invoke(info.Context);
}
private void Suspend(DialogInfo info)
{
if (info.View is null)
return;
RetailConfirmationDialogView view = info.View;
info.View = null;
view.DetachHandlers();
_openOrder.Remove(info);
_host.RemoveChild(view.Root);
RefreshModal();
}
private void DialogDone(DialogInfo info)
{
// DialogDone @ 0x004773C0 delivers the registered callback first, then
// broadcasts the CloseDialog notice, and only then removes the root.
try
{
info.Callback?.Invoke(info.Data);
DialogClosed?.Invoke(info.Context, info.Data);
}
finally
{
RemoveView(info);
}
}
private void RemoveView(DialogInfo info)
{
if (info.View is { } view)
{
view.DetachHandlers();
_host.RemoveChild(view.Root);
info.View = null;
_openOrder.Remove(info);
}
RefreshModal();
}
private void OpenNextDialog(uint queueKey)
{
// A callback invoked from DialogDone (above, in CloseDialog) may
// synchronously make a new dialog under this same queue key before
// control returns here — MakeDialog's queued branch will have
// already re-occupied _activeQueued[queueKey]. Retail's HashTable::add
// tolerates the duplicate; Dictionary.Add does not. Bail out: the
// re-entrant dialog's own eventual CloseDialog will drain the
// pending queue via its own OpenNextDialog call.
if (_activeQueued.ContainsKey(queueKey))
return;
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|| queue.First is null)
return;
DialogInfo next = queue.First.Value;
queue.RemoveFirst();
if (queue.Count == 0)
_pending.Remove(queueKey);
_activeQueued.Add(queueKey, next);
CreateDialog(next);
}
private void UpdatePendingDialogDisplays()
{
foreach ((uint queueKey, DialogInfo active) in _activeQueued)
{
int count = _pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
? queue.Count
: 0;
active.View?.SetPendingCount(count);
}
}
private void RefreshModal()
{
_host.Modal = _openOrder.Count == 0 ? null : _openOrder[^1].View?.Root;
}
}