fix(interaction): close selection lifecycle review gaps

Bind queued actions and pending inventory requests to exact live incarnations, separate optimistic placement from authoritative responses, and serialize retail-style inventory ownership across UI surfaces.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-21 09:01:02 +02:00
parent d2bb5af453
commit 5acc3f01cf
23 changed files with 2635 additions and 168 deletions

View file

@ -50,11 +50,19 @@ public readonly record struct ClientObjectPlacement(
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c> snapshots and
/// publishes both complete placements after applying the new state.
/// </summary>
public enum ClientObjectMoveOrigin
{
LocalProjection,
ServerRollbackProjection,
AuthoritativeResponse,
}
public readonly record struct ClientObjectMove(
uint ItemId,
ClientObject? Item,
ClientObjectPlacement Previous,
ClientObjectPlacement Current);
ClientObjectPlacement Current,
ClientObjectMoveOrigin Origin);
public enum ClientObjectRemovalReason
{
@ -119,7 +127,10 @@ public sealed class ClientObjectTable
/// <summary>
/// Fires after a complete placement notice (moved between packs,
/// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Item"/>
/// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Origin"/>
/// distinguishes an immediate local projection from ACE's authoritative
/// response; transaction owners must complete only on the latter.
/// <see cref="ClientObjectMove.Item"/>
/// is null when retail publishes the notice before CreateObject resolves
/// the GUID; the old/new placement remains authoritative notice data.
/// </summary>
@ -168,6 +179,13 @@ public sealed class ClientObjectTable
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
public event Action<ClientObject>? ObjectUpdated;
/// <summary>
/// Fires only for an authoritative SetStackSize response. Inventory request
/// owners use this narrower seam instead of treating an unrelated appraisal
/// or property update as the response to a merge, split, give, or drop.
/// </summary>
public event Action<ClientObject>? StackSizeUpdated;
/// <summary>Fires after all session object and pending-move state is flushed.</summary>
public event Action? Cleared;
@ -242,7 +260,8 @@ public sealed class ClientObjectTable
newSlot,
item.WielderId,
newEquipLocation),
containerTypeHint);
containerTypeHint,
ClientObjectMoveOrigin.LocalProjection);
}
/// <summary>
@ -270,13 +289,15 @@ public sealed class ClientObjectTable
newSlot,
newWielderId,
newEquipLocation),
containerTypeHint);
containerTypeHint,
ClientObjectMoveOrigin.AuthoritativeResponse);
}
private bool ApplyPlacement(
ClientObject item,
ClientObjectPlacement current,
uint? containerTypeHint = null)
uint? containerTypeHint,
ClientObjectMoveOrigin origin)
{
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
@ -289,18 +310,19 @@ public sealed class ClientObjectTable
if (containerTypeHint is { } hint)
item.ContainerTypeHint = hint;
Reindex(item, previous.ContainerId);
PublishPlacementChange(item, previous);
PublishPlacementChange(item, previous, origin);
PublishContainerContentsChanges(changedContainers);
return true;
}
private void PublishPlacementChange(
ClientObject item,
ClientObjectPlacement previous)
ClientObjectPlacement previous,
ClientObjectMoveOrigin origin)
{
ClientObjectPlacement current = ClientObjectPlacement.From(item);
UpdateEquipmentIndex(item.ObjectId, previous, current);
ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current));
ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current, origin));
}
/// <summary>
@ -337,7 +359,8 @@ public sealed class ClientObjectTable
newContainerId,
newSlot,
newWielderId,
newEquipLocation)));
newEquipLocation),
ClientObjectMoveOrigin.AuthoritativeResponse));
return false;
}
@ -364,14 +387,17 @@ public sealed class ClientObjectTable
itemId,
Item: null,
Previous: default,
Current: placement));
Current: placement,
ClientObjectMoveOrigin.AuthoritativeResponse));
WieldConfirmed?.Invoke(itemId);
return false;
}
if (!ApplyPlacement(
item,
placement))
placement,
containerTypeHint: null,
ClientObjectMoveOrigin.AuthoritativeResponse))
{
return false;
}
@ -434,7 +460,7 @@ public sealed class ClientObjectTable
}
else item.ContainerSlot = newSlot;
PublishPlacementChange(item, previous);
PublishPlacementChange(item, previous, ClientObjectMoveOrigin.LocalProjection);
PublishContainerContentsChanges(changedContainers);
return true;
}
@ -485,12 +511,12 @@ public sealed class ClientObjectTable
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
_pendingMoves.Remove(itemId);
ClientObjectPlacement placement = pre.placement;
if (!ApplyServerMove(
itemId,
placement.ContainerId,
placement.WielderId,
placement.ContainerSlot,
placement.EquipLocation))
if (!_objects.TryGetValue(itemId, out ClientObject? item)
|| !ApplyPlacement(
item,
placement,
containerTypeHint: null,
ClientObjectMoveOrigin.ServerRollbackProjection))
return false;
MoveRolledBack?.Invoke(_objects[itemId]);
return true;
@ -654,6 +680,7 @@ public sealed class ClientObjectTable
item.StackSize = stackSize;
item.Value = value;
ObjectUpdated?.Invoke(item);
StackSizeUpdated?.Invoke(item);
return true;
}

View file

@ -167,6 +167,14 @@ public sealed class MoveToManager
/// </summary>
public Action<WeenieError>? MoveToComplete { get; set; }
/// <summary>
/// CLIENT ADDITION — cancellation companion to <see cref="MoveToComplete"/>.
/// Fires only when <see cref="CancelMoveTo"/> actually tears down an
/// active move. It never changes retail movement behavior; App owners use
/// it to withdraw presentation state which was waiting for natural arrival.
/// </summary>
public Action<WeenieError>? MoveToCancelled { get; set; }
/// <summary>Retail <c>Timer::cur_time</c> — injectable clock (tests drive
/// this explicitly; production binds to the real wall/game clock).
/// Defaults to a monotonically-increasing stub if not overridden via the
@ -1455,18 +1463,18 @@ public sealed class MoveToManager
/// <see cref="_pendingActions"/>, <see cref="CleanUp"/>, then
/// StopCompletely. The <paramref name="error"/> argument is NEVER READ
/// in retail's body (every call site's error code is dropped in this
/// build, decomp §7c) — kept for parity/logging/tests only; NO behavior
/// depends on its value.
/// build, decomp §7c). The retail body remains independent of it; only the
/// documented client-addition <see cref="MoveToCancelled"/> observes it
/// after teardown so App presentation can classify the cancellation.
/// </summary>
public void CancelMoveTo(WeenieError error)
{
_ = error; // retail: never read in the body (decomp §7c) — kept for parity/logging.
if (MovementTypeState == MovementType.Invalid) return;
_pendingActions.Clear();
CleanUp();
if (HasPhysicsObj) _stopCompletely();
MoveToCancelled?.Invoke(error);
}
/// <summary>