Slice 4 made a remote character's wielded weapon selectable, which made the
pickup chain reachable end to end for the first time: SelectionPickUp on
another player's weapon captured identity, passed ValidatePickupTarget (which
checked only the Stuck flag and the small-item mask, and a MeleeWeapon clears
both), installed a real non-autonomous approach through
PlayerInteractionMovementSink, and then sent a pickup request the server
rejects. Retail does none of that.
ItemHolder::AttemptToPlaceInContainer @ 0x00588140 runs
AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0 first, at 0x00588173 --
ahead of container legality, auto-merge, the container walk, and the only
CM_Inventory::Event_PutItemInContainer emitter
(ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680). IsItemLegal's arm at
0x005872B7 rejects `!ACCWeenieObject::IsOwnedByPlayer(item) &&
item->pwd._location != 0` with one local
ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...), and
CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 then withdraws the waiting slot it
had published (SetWaitingState(obj, 0) + SendNotice_EndPendingInPlayer at
0x0055D918). No request, no movement. acdream had never ported that arm; it
was harmless while wielded children were unpickable and stopped being harmless
at f6db964f.
The notice is data_7e2228, "The %s is being wielded by someone else!" -- WITH
the exclamation mark. IsItemLegal's six strings occupy one contiguous literal
block, 0x007e21f0 through 0x007e234c, one per arm in reverse code order, and
the two neighbours already ported here (0x007e227c "The %s cannot be picked
up!" at 0x00587264, 0x007e22b4 "You cannot pick up creatures!" at 0x005871f4)
pin it. The punctuation-free 0x007cd350 variant belongs to the wield/wear
block and is emitted from a different function at 0x00560aef.
pwd._location is the PublicWeenieDesc CurrentWieldedLocation field
(acclient.h:37175), which acdream projects as
ClientObject.CurrentlyEquippedLocation, and ACCWeenieObject::IsOwnedByPlayer
@ 0x0058D160 is IsOwnedByObject(this, player_id) -- already ported as
ClientObjectTable.IsOwnedByObject @ 0x0058CEB0 and reached here through the
existing ItemInteractionController.IsOwnedByPlayer. The arm reads pwd._location
verbatim rather than adding a WielderId belt-and-braces test, because retail's
predicate is the thing being ported.
The player's OWN wielded item is IsOwnedByPlayer, so retail passes it and takes
a different route. ACCWeenieObject::DeterminePositionState @ 0x0058BE70 gives
it PositionState.WIELDED (acclient.h:6802) rather than IN_3D_VIEW, and
UIAttemptPutInContainer records IR_PICK_UP only for IN_3D_VIEW, treating
WIELDED and IN_CONTAINER alike as a plain IR_PUT_IN_CONTAINER transfer. So an
own-wielded item is unwielded in place: the request goes out immediately with
no approach, joining the existing current-ground-object shortcut. The shortcut
carries an ownership conjunct so it can never outrun the 0x005872B7 gate.
TryGetApproach now refuses attached children outright, for the same
IN_3D_VIEW reason. An Attached projection's bookkeeping WorldEntity.Position
carries the PARENT's composed root (EquippedChildRenderController
.ApplyParentWorldPose), not the child frame CPhysicsObj::UpdateChild @
0x00512D50 composes, so an approach built from it walked toward the wielder.
Slice 4 de-parented the marker anchor but left this one parent-derived; no
approach can anchor on a wielder now.
The pick predicates are deliberately untouched. Picking, selecting, examining,
lighting-pulse identity, and the vivid-marker anchor on a remote's wielded
weapon all behave exactly as Slice 4 shipped them -- retail's sr_Select and
sr_Examine branches of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 never
consult IsItemLegal. The gate is the transaction, not the pick.
f6db964f's message asserted the slice introduced no deviation and owed no
retail-divergence-register row. That was wrong: the unported 0x005872B7 arm
was a deviation it made reachable. This commit ports the arm in full, matches
retail on the own-wielded path, and removes the parent-derived approach
anchor, so the record is corrected here and no register row is owed.
Gates: dotnet build green; AcDream.App.Tests 3,960 passed / 3 skipped;
complete Release solution 9,792 passed / 5 skipped;
tools\run-connected-world-lifecycle-gate.ps1 -SkipBuild RESULT=PASS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
646 lines
24 KiB
C#
646 lines
24 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Ui;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.UI.Abstractions.Input;
|
|
|
|
namespace AcDream.App.Interaction;
|
|
|
|
/// <summary>
|
|
/// Owns world selection mutations and one-shot Use/PickUp interaction state.
|
|
/// Read-only classification stays in <see cref="WorldSelectionQuery"/>;
|
|
/// retained item policy stays in <see cref="ItemInteractionController"/>.
|
|
/// </summary>
|
|
internal sealed class SelectionInteractionController
|
|
{
|
|
private readonly SelectionState _selection;
|
|
private readonly IWorldSelectionQuery _query;
|
|
private readonly ItemInteractionController _items;
|
|
private readonly RuntimeInteractionTransactionState _transactions;
|
|
private readonly IRuntimeInteractionTransport _transport;
|
|
private readonly IPlayerInteractionMovementSink _movement;
|
|
private readonly PlayerApproachCompletionState _approachCompletions;
|
|
private readonly Action<string>? _toast;
|
|
|
|
public SelectionInteractionController(
|
|
SelectionState selection,
|
|
IWorldSelectionQuery query,
|
|
ItemInteractionController items,
|
|
IRuntimeInteractionTransport transport,
|
|
IPlayerInteractionMovementSink movement,
|
|
Action<string>? toast = null,
|
|
PlayerApproachCompletionState? approachCompletions = null)
|
|
{
|
|
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
|
_query = query ?? throw new ArgumentNullException(nameof(query));
|
|
_items = items ?? throw new ArgumentNullException(nameof(items));
|
|
_transactions = _items.RuntimeTransactions;
|
|
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
|
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
|
|
_toast = toast;
|
|
_approachCompletions = approachCompletions
|
|
?? new PlayerApproachCompletionState();
|
|
}
|
|
|
|
public bool HandleInputAction(InputAction action)
|
|
{
|
|
switch (action)
|
|
{
|
|
case InputAction.SelectionClosestMonster:
|
|
SelectClosestCombatTarget(showToast: true);
|
|
return true;
|
|
case InputAction.SelectionPreviousSelection:
|
|
_selection.SelectPrevious();
|
|
return true;
|
|
case InputAction.SelectLeft:
|
|
PickAndStoreSelection(useImmediately: false);
|
|
return true;
|
|
case InputAction.SelectRight:
|
|
PickSelectAndExamine();
|
|
return true;
|
|
case InputAction.SelectDblLeft:
|
|
PickAndStoreSelection(useImmediately: true);
|
|
return true;
|
|
case InputAction.SelectionExamine:
|
|
_items.ExamineSelectedOrEnterMode(
|
|
_selection.SelectedObjectId ?? 0u);
|
|
return true;
|
|
case InputAction.UseSelected:
|
|
UseCurrentSelection();
|
|
return true;
|
|
case InputAction.SelectionPickUp:
|
|
if (_selection.SelectedObjectId is uint pickupTarget)
|
|
{
|
|
EnqueueIdentityBound(
|
|
RuntimeQueuedInteractionKind.Pickup,
|
|
pickupTarget,
|
|
requireLiveEntity: true);
|
|
}
|
|
else
|
|
{
|
|
_toast?.Invoke("Nothing selected");
|
|
}
|
|
return true;
|
|
case InputAction.EscapeKey when _items.IsAnyTargetModeActive:
|
|
_items.CancelTargetMode();
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public uint? PickAtCursor(bool includeSelf)
|
|
=> _query.PickAtCursor(includeSelf);
|
|
|
|
public void PlaceDraggedItem(ItemDragPayload payload, float mouseX, float mouseY)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(payload);
|
|
uint target = _query.PickAt(mouseX, mouseY, includeSelf: true) ?? 0u;
|
|
if (target != 0u)
|
|
_query.BeginLightingPulse(target);
|
|
_items.PlaceIn3D(payload, target);
|
|
}
|
|
|
|
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
|
|
{
|
|
if (_selection.SelectedObjectId is { } selected
|
|
&& _query.IsHostileMonster(selected))
|
|
{
|
|
return selected;
|
|
}
|
|
return autoTarget ? SelectClosestCombatTarget(showToast: false) : null;
|
|
}
|
|
|
|
public uint? SelectClosestCombatTarget(bool showToast)
|
|
{
|
|
ClosestCombatTarget? closest = _query.FindClosestHostileMonster();
|
|
uint? bestGuid = closest?.ServerGuid;
|
|
if (bestGuid is { } selected)
|
|
_selection.Select(selected, SelectionChangeSource.Keyboard);
|
|
else
|
|
_selection.Clear(SelectionChangeSource.Keyboard);
|
|
|
|
if (bestGuid is { } guid)
|
|
{
|
|
string label = _query.Describe(guid);
|
|
float distance = MathF.Sqrt(closest!.Value.DistanceSquared);
|
|
Console.WriteLine($"combat: selected target 0x{guid:X8} {label} dist={distance:F1}");
|
|
if (showToast)
|
|
_toast?.Invoke($"Target {label}");
|
|
}
|
|
else if (showToast)
|
|
{
|
|
_toast?.Invoke("No monster target");
|
|
Console.WriteLine("combat: no creature target found");
|
|
}
|
|
return bestGuid;
|
|
}
|
|
|
|
public void PickAndStoreSelection(bool useImmediately)
|
|
{
|
|
uint? picked = _query.PickAtCursor(_items.IsAnyTargetModeActive);
|
|
if (picked is not uint guid)
|
|
{
|
|
if (!_items.IsAnyTargetModeActive)
|
|
_toast?.Invoke("Nothing to select");
|
|
return;
|
|
}
|
|
|
|
// UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
|
|
// @ 0x004E5AD0 pulses before select/use/target-mode branching.
|
|
_query.BeginLightingPulse(guid);
|
|
if (_items.OfferPrimaryClick(guid) is not ItemPrimaryClickResult.NotActive)
|
|
return;
|
|
|
|
_selection.Select(guid, SelectionChangeSource.World);
|
|
string label = _query.Describe(guid);
|
|
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
|
_toast?.Invoke($"Selected: {label}");
|
|
// The sr_Use branch of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0
|
|
// guards ItemHolder::UseObject with
|
|
// `if (found->pwd._wielderID != SmartBox::player_id)` at 0x004E5BE9.
|
|
// Clicking your own wielded weapon selects and flashes it but sends no
|
|
// Use. Equipped-child picking makes that click reachable, so the gate
|
|
// ships with it.
|
|
if (useImmediately && !_query.IsWieldedByPlayer(guid))
|
|
EnqueueIdentityBound(
|
|
RuntimeQueuedInteractionKind.Activate,
|
|
guid,
|
|
requireLiveEntity: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail SmartBox right-click path:
|
|
/// <c>UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820</c> chooses
|
|
/// <c>sr_Examine</c>, then
|
|
/// <c>RecvNotice_SmartBoxObjectFound @ 0x004E5AD0</c> pulses, selects,
|
|
/// and calls <c>ClientUISystem::ExamineObject</c>. Empty space is a no-op,
|
|
/// and this path does not consume a left-click target mode.
|
|
/// </summary>
|
|
public void PickSelectAndExamine()
|
|
{
|
|
uint? picked = _query.PickAtCursor(includeSelf: false);
|
|
if (picked is not uint guid)
|
|
return;
|
|
|
|
_query.BeginLightingPulse(guid);
|
|
_selection.Select(guid, SelectionChangeSource.World);
|
|
_items.ExamineSelectedOrEnterMode(guid);
|
|
}
|
|
|
|
public void UseCurrentSelection()
|
|
{
|
|
if (_selection.SelectedObjectId is not uint selected)
|
|
{
|
|
_toast?.Invoke("Nothing selected");
|
|
return;
|
|
}
|
|
EnqueueIdentityBound(
|
|
RuntimeQueuedInteractionKind.Use,
|
|
selected,
|
|
requireLiveEntity: false);
|
|
}
|
|
|
|
public void SendUse(uint serverGuid)
|
|
=> RequestUse(serverGuid, reservation: null);
|
|
|
|
public void RequestUse(
|
|
uint serverGuid,
|
|
ItemUseRequestReservation? reservation)
|
|
{
|
|
CancelPendingApproach();
|
|
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
|
|
RuntimeInteractionDispatchResult result =
|
|
_transactions.TryDispatchUse(
|
|
serverGuid,
|
|
ownedByPlayer,
|
|
ownedByPlayer || _query.IsUseable(serverGuid),
|
|
reservation,
|
|
_transport,
|
|
out uint sequence);
|
|
if (result == RuntimeInteractionDispatchResult.NotInWorld)
|
|
_toast?.Invoke("Not in world");
|
|
if (result == RuntimeInteractionDispatchResult.Dispatched)
|
|
Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}");
|
|
}
|
|
|
|
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
|
|
{
|
|
CancelPendingApproach();
|
|
if (!_transport.IsInWorld)
|
|
{
|
|
_toast?.Invoke("Not in world");
|
|
CancelPickupPresentation(itemGuid);
|
|
return;
|
|
}
|
|
|
|
ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement(
|
|
itemGuid,
|
|
out PendingBackpackPlacement pendingPlacement)
|
|
? pendingPlacement.Token
|
|
: 0u;
|
|
if (!IsCurrentPickupPresentation(
|
|
itemGuid,
|
|
destinationContainerId,
|
|
placement,
|
|
pendingPlacementToken))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Retail CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 delegates
|
|
// current-ground-object contents straight to
|
|
// ItemHolder::AttemptToPlaceInContainer @ 0x00588140. A corpse or
|
|
// chest child has no independent 3-D projection to approach.
|
|
//
|
|
// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 records the
|
|
// IR_PICK_UP world request only for PositionState.IN_3D_VIEW and
|
|
// treats WIELDED as a plain IR_PUT_IN_CONTAINER transfer, so the
|
|
// player's own wielded item is unwielded in place with no approach.
|
|
// The ownership conjunct keeps this shortcut behind IsItemLegal's
|
|
// 0x005872B7 arm, which ValidatePickupTarget enforces below: a wielded
|
|
// item that is not the player's must never reach a wire request.
|
|
if (_items.IsInCurrentGroundObject(itemGuid)
|
|
|| (_query.IsWieldedPositionState(itemGuid)
|
|
&& _items.IsOwnedByPlayer(itemGuid)))
|
|
{
|
|
var contained = new RuntimePendingPickup(
|
|
Token: 0u,
|
|
itemGuid,
|
|
LocalEntityId: 0u,
|
|
destinationContainerId,
|
|
placement,
|
|
pendingPlacementToken,
|
|
ApproachToken: default);
|
|
if (_transactions.TryDispatchPickup(
|
|
contained,
|
|
_transport,
|
|
out uint containedSequence))
|
|
{
|
|
Console.WriteLine(
|
|
$"[B.5] contained pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={containedSequence}");
|
|
}
|
|
else
|
|
{
|
|
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!ValidatePickupTarget(itemGuid, showToast: true)
|
|
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
|
|
{
|
|
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
|
return;
|
|
}
|
|
|
|
if (approach.IsCloseRange)
|
|
{
|
|
bool armed = false;
|
|
bool started = _movement.BeginApproach(
|
|
approach,
|
|
token =>
|
|
{
|
|
armed = _transactions.TryArmPostArrivalPickup(
|
|
itemGuid,
|
|
approach.Target.LocalEntityId,
|
|
destinationContainerId,
|
|
placement,
|
|
pendingPlacementToken,
|
|
new RuntimeInteractionApproachToken(
|
|
token.ControllerLifetime,
|
|
token.ApproachGeneration),
|
|
out _);
|
|
});
|
|
if (!started || !armed)
|
|
{
|
|
if (_transactions.TryCancelPendingPickup(
|
|
itemGuid,
|
|
approach.Target.LocalEntityId,
|
|
out RuntimePendingPickup cancelled))
|
|
{
|
|
CancelPickupPresentation(
|
|
cancelled.ServerGuid,
|
|
cancelled.PendingPlacementToken);
|
|
}
|
|
else
|
|
{
|
|
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
_movement.BeginApproach(approach);
|
|
if (!IsCurrentPickupPresentation(
|
|
itemGuid,
|
|
destinationContainerId,
|
|
placement,
|
|
pendingPlacementToken))
|
|
{
|
|
return;
|
|
}
|
|
var immediate = new RuntimePendingPickup(
|
|
Token: 0u,
|
|
itemGuid,
|
|
approach.Target.LocalEntityId,
|
|
destinationContainerId,
|
|
placement,
|
|
pendingPlacementToken,
|
|
ApproachToken: default);
|
|
if (_transactions.TryDispatchPickup(
|
|
immediate,
|
|
_transport,
|
|
out uint sequence))
|
|
{
|
|
Console.WriteLine(
|
|
$"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}");
|
|
}
|
|
else
|
|
{
|
|
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
|
|
public void OnNaturalMoveToComplete()
|
|
{
|
|
if (_transactions.TryGetPendingPickup(out RuntimePendingPickup pending))
|
|
HandleApproachCompletion(pending.ApproachToken, natural: true);
|
|
}
|
|
|
|
private void HandleApproachCompletion(
|
|
RuntimeInteractionApproachToken approachToken,
|
|
bool natural)
|
|
{
|
|
bool accepted = _transactions.TryResolveApproachCompletion(
|
|
approachToken,
|
|
natural,
|
|
out RuntimePendingPickup pending);
|
|
if (pending.Token == 0u)
|
|
return;
|
|
if (!accepted)
|
|
{
|
|
CancelPickupPresentation(
|
|
pending.ServerGuid,
|
|
pending.PendingPlacementToken);
|
|
return;
|
|
}
|
|
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
|
|
{
|
|
CancelPickupPresentation(
|
|
pending.ServerGuid,
|
|
pending.PendingPlacementToken);
|
|
return;
|
|
}
|
|
|
|
if (!IsCurrentPickupPresentation(
|
|
pending.ServerGuid,
|
|
pending.DestinationContainerId,
|
|
pending.Placement,
|
|
pending.PendingPlacementToken))
|
|
{
|
|
return;
|
|
}
|
|
if (!_transactions.TryDispatchPickup(
|
|
pending,
|
|
_transport,
|
|
out _))
|
|
{
|
|
CancelPickupPresentation(
|
|
pending.ServerGuid,
|
|
pending.PendingPlacementToken);
|
|
}
|
|
}
|
|
|
|
public void DrainOutbound()
|
|
{
|
|
while (_approachCompletions.TryTake(out PlayerApproachCompletion completion))
|
|
{
|
|
HandleApproachCompletion(
|
|
new RuntimeInteractionApproachToken(
|
|
completion.Token.ControllerLifetime,
|
|
completion.Token.ApproachGeneration),
|
|
completion.IsNatural);
|
|
}
|
|
_transactions.DrainOutbound(DispatchQueuedInteraction);
|
|
}
|
|
|
|
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
|
|
|
|
public void OnEntityHidden(uint serverGuid)
|
|
{
|
|
_transactions.CancelQueuedInteractions(serverGuid);
|
|
if (_transactions.TryCancelPendingPickup(
|
|
serverGuid,
|
|
localEntityId: null,
|
|
out RuntimePendingPickup cancelled))
|
|
{
|
|
CancelPickupPresentation(
|
|
cancelled.ServerGuid,
|
|
cancelled.PendingPlacementToken);
|
|
}
|
|
if (_selection.SelectedObjectId == serverGuid)
|
|
{
|
|
_selection.Clear(
|
|
SelectionChangeSource.System,
|
|
SelectionChangeReason.Cleared);
|
|
}
|
|
}
|
|
|
|
public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(record);
|
|
_transactions.CancelQueuedInteractions(
|
|
record.ServerGuid,
|
|
record.LocalEntityId);
|
|
if (_transactions.TryCancelPendingPickup(
|
|
record.ServerGuid,
|
|
record.LocalEntityId,
|
|
out RuntimePendingPickup cancelled))
|
|
{
|
|
CancelPickupPresentation(
|
|
cancelled.ServerGuid,
|
|
cancelled.PendingPlacementToken);
|
|
}
|
|
if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid)
|
|
{
|
|
_selection.Clear(
|
|
SelectionChangeSource.System,
|
|
SelectionChangeReason.SelectedObjectRemoved);
|
|
}
|
|
}
|
|
|
|
public void ResetSession()
|
|
{
|
|
List<Exception> failures = [];
|
|
try { CancelPendingApproach(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
try { _items.ResetSession(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
try { _selection.Reset(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
try { _approachCompletions.Clear(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
|
|
if (failures.Count != 0)
|
|
throw new AggregateException(
|
|
"One or more selection-interaction reset stages failed.",
|
|
failures);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retires only App approach and retained item-interaction presentation.
|
|
/// Runtime resets the canonical interaction and selection owners once at
|
|
/// the shared generation boundary.
|
|
/// </summary>
|
|
internal void ResetGenerationPresentation()
|
|
{
|
|
List<Exception> failures = [];
|
|
try { CancelPendingApproach(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
try { _items.ResetGenerationPresentation(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
try { _approachCompletions.Clear(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
|
|
if (failures.Count != 0)
|
|
{
|
|
throw new AggregateException(
|
|
"One or more selection-interaction presentation reset stages failed.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>ItemHolder::AttemptToPlaceInContainer @ 0x00588140</c> runs
|
|
/// <c>AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0</c> first, at
|
|
/// <c>0x00588173</c> — ahead of container legality, auto-merge, the
|
|
/// container walk, and the only <c>CM_Inventory::Event_PutItemInContainer</c>
|
|
/// emitter (<c>ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680</c>).
|
|
/// A rejection is therefore one local
|
|
/// <c>ECM_UI::SendNotice_DisplayStringInfo(0x1a, …)</c> and nothing else:
|
|
/// no wire request and no movement.
|
|
/// <c>CPlayerSystem::PlaceInBackpack @ 0x0055D8C0</c> then withdraws the
|
|
/// waiting slot it had published (<c>SetWaitingState(obj, 0)</c> plus
|
|
/// <c>CM_Item::SendNotice_EndPendingInPlayer</c> at <c>0x0055D918</c>),
|
|
/// which is what a <c>false</c> return drives here.
|
|
/// </summary>
|
|
private bool ValidatePickupTarget(uint serverGuid, bool showToast)
|
|
{
|
|
if (_query.IsCreature(serverGuid))
|
|
{
|
|
if (showToast)
|
|
_toast?.Invoke(RetailMessages.CannotPickUpCreatures);
|
|
return false;
|
|
}
|
|
// IsItemLegal's arm at 0x005872B7 rejects
|
|
// `!ACCWeenieObject::IsOwnedByPlayer(item) && item->pwd._location != 0`
|
|
// with the notice at 0x005872DB. Retail runs it after the stuck arm at
|
|
// 0x00587240; the two are disjoint, because the stuck arm fires only
|
|
// when _containerID and _wielderID are both zero while this one needs a
|
|
// wielded _location, so acdream's fused stuck/type predicate may follow.
|
|
// Equipped-child picking made a remote character's wielded item
|
|
// selectable, which is what makes this arm reachable.
|
|
if (_query.IsWieldedPositionState(serverGuid)
|
|
&& !_items.IsOwnedByPlayer(serverGuid))
|
|
{
|
|
if (showToast)
|
|
{
|
|
_toast?.Invoke(RetailMessages.BeingWieldedBySomeoneElse(
|
|
_query.Describe(serverGuid)));
|
|
}
|
|
return false;
|
|
}
|
|
if (_query.IsPickupable(serverGuid))
|
|
return true;
|
|
if (showToast)
|
|
_toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(serverGuid)));
|
|
return false;
|
|
}
|
|
|
|
private bool EnqueueIdentityBound(
|
|
RuntimeQueuedInteractionKind kind,
|
|
uint serverGuid,
|
|
bool requireLiveEntity)
|
|
{
|
|
uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId)
|
|
? localId
|
|
: null;
|
|
ClientObject? item = _items.TryCaptureObjectIdentity(serverGuid, out ClientObject captured)
|
|
? captured
|
|
: null;
|
|
if ((requireLiveEntity && localEntityId is null)
|
|
|| (localEntityId is null && item is null))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var identity = new RuntimeInteractionIdentity(
|
|
serverGuid,
|
|
localEntityId,
|
|
item);
|
|
_transactions.Enqueue(new RuntimeQueuedInteraction(kind, identity));
|
|
return true;
|
|
}
|
|
|
|
private bool IsCurrent(RuntimeInteractionIdentity identity)
|
|
=> (identity.LocalEntityId is not uint localId
|
|
|| _query.IsCurrent(identity.ServerGuid, localId))
|
|
&& (identity.ClientObject is not { } item
|
|
|| _items.IsCurrentObjectIdentity(identity.ServerGuid, item));
|
|
|
|
private void CancelPendingApproach()
|
|
{
|
|
if (!_transactions.TryCancelPendingPickup(
|
|
out RuntimePendingPickup pending))
|
|
return;
|
|
CancelPickupPresentation(
|
|
pending.ServerGuid,
|
|
pending.PendingPlacementToken);
|
|
}
|
|
|
|
private void DispatchQueuedInteraction(
|
|
RuntimeQueuedInteraction interaction)
|
|
{
|
|
RuntimeInteractionIdentity identity = interaction.Identity;
|
|
if (!IsCurrent(identity))
|
|
return;
|
|
|
|
switch (interaction.Kind)
|
|
{
|
|
case RuntimeQueuedInteractionKind.Activate:
|
|
_items.ActivateItem(identity.ServerGuid);
|
|
break;
|
|
case RuntimeQueuedInteractionKind.Use:
|
|
_items.UseSelectedOrEnterMode(identity.ServerGuid);
|
|
break;
|
|
case RuntimeQueuedInteractionKind.Pickup:
|
|
if (ValidatePickupTarget(identity.ServerGuid, showToast: true))
|
|
_items.PlaceWorldItemInBackpack(identity.ServerGuid);
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException(
|
|
$"Unknown queued interaction kind {interaction.Kind}.");
|
|
}
|
|
}
|
|
|
|
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
|
|
=> _items.CancelPendingBackpackPlacement(itemGuid, token);
|
|
|
|
private bool IsCurrentPickupPresentation(
|
|
uint itemGuid,
|
|
uint destinationContainerId,
|
|
int placement,
|
|
ulong token)
|
|
=> token != 0u
|
|
&& _items.TryGetPendingBackpackPlacement(
|
|
itemGuid,
|
|
out PendingBackpackPlacement pending)
|
|
&& pending.Token == token
|
|
&& pending.ContainerId == destinationContainerId
|
|
&& pending.Placement == placement;
|
|
}
|