fix(runtime): restore interaction completion ownership

Preserve prepublication local motion completion, require the PartArray enter-world lifecycle port, and balance deferred Use busy ownership across dispatch and cancellation. Reconcile the completed GameWindow connected gates and add regression coverage.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 05:51:51 +02:00
parent 5c955c36ec
commit f9736ece6c
26 changed files with 675 additions and 68 deletions

View file

@ -37,6 +37,32 @@ public readonly record struct PendingInventoryRequest(
ClientObject? ItemIdentity,
bool Dispatched);
/// <summary>
/// Owns one retail UI busy reference while an accepted Use request crosses the
/// local approach boundary. Dispatch transfers that reference to the matching
/// authoritative UseDone; cancellation before dispatch releases it locally.
/// </summary>
public sealed class ItemUseRequestReservation
{
private readonly Action<bool> _resolve;
private int _resolved;
internal ItemUseRequestReservation(Action<bool> resolve)
=> _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
public void MarkDispatched()
{
if (Interlocked.Exchange(ref _resolved, 1) == 0)
_resolve(true);
}
public void CancelBeforeDispatch()
{
if (Interlocked.Exchange(ref _resolved, 1) == 0)
_resolve(false);
}
}
/// <summary>
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
/// target acquisition, and drag-out drops here instead of duplicating
@ -78,6 +104,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Func<bool> _dragOnPlayerOpensSecureTrade;
private readonly Action<string>? _systemMessage;
private readonly AutoWieldController _autoWield;
private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
private long _lastUseMs = long.MinValue / 2;
private uint _consumedPrimaryClickTarget;
@ -86,6 +113,7 @@ public sealed class ItemInteractionController : IDisposable
private ulong _nextInventoryRequestToken;
private PendingBackpackPlacement? _pendingBackpackPlacement;
private PendingInventoryRequest? _pendingInventoryRequest;
private ulong _useReservationGeneration;
private bool _disposed;
public ItemInteractionController(
@ -118,7 +146,8 @@ public sealed class ItemInteractionController : IDisposable
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
Action<uint>? requestExternalContainer = null,
CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null)
Action<CombatMode>? sendChangeCombatMode = null,
Action<uint, ItemUseRequestReservation>? requestUse = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -147,6 +176,7 @@ public sealed class ItemInteractionController : IDisposable
_stackSplitQuantity = stackSplitQuantity;
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
_systemMessage = systemMessage;
_requestUse = requestUse;
_interactionState = interactionState ?? new InteractionState();
_interactionState.Changed += OnInteractionModeChanged;
_objects.ObjectMoved += OnInventoryObjectMoved;
@ -757,12 +787,28 @@ public sealed class ItemInteractionController : IDisposable
/// </summary>
public bool ExecuteConfirmedUse(uint objectId)
{
if (objectId == 0u || _sendUse is null)
if (objectId == 0u || (_requestUse is null && _sendUse is null))
return false;
if (!EnsureInventoryRequestReady())
return false;
_sendUse(objectId);
_busyCount++;
if (_requestUse is not null)
{
ItemUseRequestReservation reservation = BeginUseRequestReservation();
try
{
_requestUse(objectId, reservation);
}
catch
{
reservation.CancelBeforeDispatch();
throw;
}
}
else
{
_sendUse!(objectId);
_busyCount++;
}
return true;
}
@ -815,6 +861,7 @@ public sealed class ItemInteractionController : IDisposable
{
uint openedOrUsed = 0;
bool acted = false;
bool busyOwnedByUseReservation = false;
foreach (var action in actions)
{
switch (action.Kind)
@ -835,9 +882,27 @@ public sealed class ItemInteractionController : IDisposable
// coalesce the pair to one packet.
if (openedOrUsed != action.ObjectId)
{
_sendUse?.Invoke(action.ObjectId);
if (_requestUse is not null)
{
ItemUseRequestReservation reservation =
BeginUseRequestReservation();
try
{
_requestUse(action.ObjectId, reservation);
}
catch
{
reservation.CancelBeforeDispatch();
throw;
}
busyOwnedByUseReservation = true;
}
else
{
_sendUse?.Invoke(action.ObjectId);
}
openedOrUsed = action.ObjectId;
acted |= _sendUse is not null;
acted |= _requestUse is not null || _sendUse is not null;
}
break;
case ItemPolicyActionKind.SendUseWithTarget:
@ -853,7 +918,14 @@ public sealed class ItemInteractionController : IDisposable
acted = true;
break;
case ItemPolicyActionKind.IncrementBusy:
_busyCount++;
if (busyOwnedByUseReservation)
{
busyOwnedByUseReservation = false;
}
else
{
_busyCount++;
}
break;
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
@ -872,6 +944,21 @@ public sealed class ItemInteractionController : IDisposable
return acted;
}
private ItemUseRequestReservation BeginUseRequestReservation()
{
ulong generation = _useReservationGeneration;
_busyCount++;
StateChanged?.Invoke();
return new ItemUseRequestReservation(dispatched =>
{
if (generation != _useReservationGeneration || dispatched)
return;
if (_busyCount > 0)
_busyCount--;
StateChanged?.Invoke();
});
}
private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList<ItemPolicyAction> actions)
{
foreach (var action in actions)
@ -1120,6 +1207,7 @@ public sealed class ItemInteractionController : IDisposable
public void ClearBusy()
{
if (_busyCount == 0) return;
_useReservationGeneration++;
_busyCount = 0;
StateChanged?.Invoke();
}
@ -1133,6 +1221,7 @@ public sealed class ItemInteractionController : IDisposable
{
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
_pendingBackpackPlacement = null;
_useReservationGeneration++;
_busyCount = 0;
_pendingInventoryRequest = null;
_lastUseMs = long.MinValue / 2;