acdream/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
Erik f9736ece6c 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>
2026-07-23 05:51:51 +02:00

708 lines
23 KiB
C#

using System.Numerics;
using AcDream.App.Interaction;
using AcDream.App.UI;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Interaction;
public sealed class SelectionInteractionControllerTests
{
private const uint Player = 0x5000_0001u;
private const uint Target = 0x7000_0001u;
private sealed class Query : IWorldSelectionQuery
{
public uint? Picked { get; set; }
public bool LastIncludeSelf { get; private set; }
public bool Current { get; set; } = true;
public bool Creature { get; set; }
public bool Hostile { get; set; }
public bool Useable { get; set; } = true;
public bool Pickupable { get; set; } = true;
public bool CaptureIdentity { get; set; } = true;
public uint LocalEntityId { get; set; } = 101u;
public ClosestCombatTarget? Closest { get; set; }
public InteractionApproach? Approach { get; set; }
public List<string> Events { get; } = new();
public uint? PickAtCursor(bool includeSelf)
{
LastIncludeSelf = includeSelf;
Events.Add("pick");
return Picked;
}
public uint? PickAt(float mouseX, float mouseY, bool includeSelf)
=> PickAtCursor(includeSelf);
public void BeginLightingPulse(uint serverGuid) => Events.Add("pulse");
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
{
localEntityId = LocalEntityId;
return CaptureIdentity;
}
public bool IsCurrent(uint serverGuid, uint localEntityId) => Current;
public string Describe(uint serverGuid) => $"Target {serverGuid:X8}";
public bool IsCreature(uint serverGuid) => Creature;
public bool IsHostileMonster(uint serverGuid) => Hostile;
public ClosestCombatTarget? FindClosestHostileMonster() => Closest;
public bool IsUseable(uint serverGuid) => Useable;
public bool IsPickupable(uint serverGuid) => Pickupable;
public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null;
public bool TryGetApproach(uint serverGuid, out InteractionApproach approach)
{
if (Approach is { } found)
{
approach = found;
return true;
}
approach = default;
return false;
}
}
private sealed class Transport : ISelectionInteractionTransport
{
private uint _sequence;
public bool IsInWorld { get; set; } = true;
public List<uint> Uses { get; } = new();
public List<(uint Item, uint Container, int Placement)> Pickups { get; } = new();
public bool TrySendUse(uint serverGuid, out uint sequence)
{
if (!IsInWorld)
{
sequence = 0u;
return false;
}
sequence = ++_sequence;
Uses.Add(serverGuid);
return true;
}
public bool TrySendPickup(
uint itemGuid,
uint destinationContainerId,
int placement,
out uint sequence)
{
if (!IsInWorld)
{
sequence = 0u;
return false;
}
sequence = ++_sequence;
Pickups.Add((itemGuid, destinationContainerId, placement));
return true;
}
}
private sealed class Movement(IPlayerApproachTokenSource approachTokens)
: IPlayerInteractionMovementSink
{
public List<InteractionApproach> Approaches { get; } = new();
public bool Starts { get; set; } = true;
public Action? AfterArm { get; set; }
public bool BeginApproach(
InteractionApproach approach,
Action<PlayerApproachToken>? armAfterCancel = null)
{
Approaches.Add(approach);
if (!Starts || !approachTokens.TryBeginApproach(out PlayerApproachToken token))
return false;
armAfterCancel?.Invoke(token);
AfterArm?.Invoke();
return true;
}
}
private sealed class Harness
{
public readonly Query Query = new();
public readonly Transport Transport = new();
public readonly PlayerApproachCompletionState Completions = new();
public readonly IPlayerApproachCompletionSink CompletionLifetime;
public readonly Movement Movement;
public readonly SelectionState Selection = new();
public readonly ClientObjectTable Objects = new();
public readonly List<string> Toasts = new();
public readonly List<uint> Examines = new();
public readonly List<PendingBackpackPlacement> PendingPlacements = new();
public readonly List<PendingBackpackPlacement> CancelledPlacements = new();
public readonly ItemInteractionController Items;
public readonly SelectionInteractionController Controller;
public Harness()
{
CompletionLifetime = Completions.BeginControllerLifetime();
Movement = new Movement(Completions);
SelectionInteractionController? controller = null;
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Type = ItemType.Creature,
});
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Target,
Name = "Target",
Type = ItemType.Creature,
Useability = ItemUseability.Remote,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Stuck,
});
Items = new ItemInteractionController(
Objects,
() => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
sendExamine: guid =>
{
Query.Events.Add("examine");
Examines.Add(guid);
},
placeInBackpack: (item, container, placement) =>
controller!.SendPickup(item, container, placement),
requestUse: (guid, reservation) =>
controller!.RequestUse(guid, reservation));
Controller = controller = new SelectionInteractionController(
Selection,
Query,
Items,
Transport,
Movement,
Toasts.Add,
Completions);
Items.PendingBackpackPlacementRequested += PendingPlacements.Add;
Items.PendingBackpackPlacementCancelled += CancelledPlacements.Add;
}
public void SetApproach(bool closeRange, uint localEntityId = 101u)
{
var entity = new WorldEntity
{
Id = localEntityId,
ServerGuid = Target,
SourceGfxObjOrSetupId = 0x0200_0001u,
Position = new Vector3(5f, 0f, 0f),
Rotation = Quaternion.Identity,
MeshRefs = [],
};
Query.Approach = new InteractionApproach(
new WorldInteractionTarget(Target, localEntityId, entity),
new PlayerInteractionPose(0x0101_0001u, Vector3.Zero),
0.6f,
closeRange,
CanCharge: !closeRange,
TargetRadius: 0.5f,
TargetHeight: 2f);
}
}
[Fact]
public void TargetModeClickPulsesBeforeItIsConsumedAndIncludesSelf()
{
var h = new Harness();
h.Query.Picked = Target;
h.Items.InteractionState.EnterExamine();
Assert.True(h.Controller.HandleInputAction(InputAction.SelectLeft));
Assert.True(h.Query.LastIncludeSelf);
Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events);
Assert.Equal(new[] { Target }, h.Examines);
Assert.Null(h.Selection.SelectedObjectId);
}
[Fact]
public void ClosestTargetInputMutatesSelectionOnce()
{
var h = new Harness();
h.Query.Closest = new ClosestCombatTarget(Target, 25f);
Assert.True(h.Controller.HandleInputAction(InputAction.SelectionClosestMonster));
Assert.Equal(Target, h.Selection.SelectedObjectId);
Assert.Contains(h.Toasts, text => text.Contains("Target 70000001"));
}
[Fact]
public void CloseUseTurnsFirstThenSendsExactlyOnceOnNaturalCompletion()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Controller.SendUse(Target);
Assert.Single(h.Movement.Approaches);
Assert.Empty(h.Transport.Uses);
h.Controller.OnNaturalMoveToComplete();
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
[Fact]
public void CancelledDeferredUseReleasesBusyWithoutServerCompletion()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
Assert.Equal(1, h.Items.BusyCount);
Assert.Empty(h.Transport.Uses);
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
h.Controller.DrainOutbound();
Assert.Equal(0, h.Items.BusyCount);
Assert.True(h.Items.CanMakeInventoryRequest);
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void DispatchedDeferredUseRemainsBusyUntilAuthoritativeUseDone()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
h.CompletionLifetime.PublishNaturalCompletion();
h.Controller.DrainOutbound();
Assert.Equal(new[] { Target }, h.Transport.Uses);
Assert.Equal(1, h.Items.BusyCount);
h.Items.CompleteUse(0u);
Assert.Equal(0, h.Items.BusyCount);
Assert.True(h.Items.CanMakeInventoryRequest);
}
[Fact]
public void FailedDeferredUseStartReleasesItsBusyReservation()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Movement.Starts = false;
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
Assert.Equal(0, h.Items.BusyCount);
Assert.True(h.Items.CanMakeInventoryRequest);
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void StaleDeferredUseReleasesItsBusyReservation()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
Assert.Equal(1, h.Items.BusyCount);
h.Query.Current = false;
h.CompletionLifetime.PublishNaturalCompletion();
h.Controller.DrainOutbound();
Assert.Equal(0, h.Items.BusyCount);
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void SynchronousTurnCompletionTransfersBusyOwnershipToUseDone()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Movement.AfterArm = h.Controller.OnNaturalMoveToComplete;
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
Assert.Equal(new[] { Target }, h.Transport.Uses);
Assert.Equal(1, h.Items.BusyCount);
h.Items.CompleteUse(0u);
Assert.Equal(0, h.Items.BusyCount);
}
[Fact]
public void FarUseSendsImmediatelyAndNaturalCompletionDoesNotRetry()
{
var h = new Harness();
h.SetApproach(closeRange: false);
h.Controller.SendUse(Target);
h.Controller.OnNaturalMoveToComplete();
Assert.Single(h.Movement.Approaches);
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
[Fact]
public void HiddenOrStaleIncarnationCancelsDeferredAction()
{
var hidden = new Harness();
hidden.SetApproach(closeRange: true);
hidden.Controller.SendUse(Target);
hidden.Controller.OnEntityHidden(Target);
hidden.Controller.OnNaturalMoveToComplete();
Assert.Empty(hidden.Transport.Uses);
var stale = new Harness();
stale.SetApproach(closeRange: true);
stale.Controller.SendUse(Target);
stale.Query.Current = false;
stale.Controller.OnNaturalMoveToComplete();
Assert.Empty(stale.Transport.Uses);
}
[Fact]
public void PickupInputWaitsForFrameDrainThenUsesPendingDestination()
{
var h = new Harness();
h.SetApproach(closeRange: false);
h.Selection.Select(Target, SelectionChangeSource.World);
Assert.True(h.Controller.HandleInputAction(InputAction.SelectionPickUp));
Assert.Empty(h.Transport.Pickups);
h.Controller.DrainOutbound();
Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups);
}
[Fact]
public void SessionResetClearsQueuedAndDeferredWork()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.SelectionPickUp);
h.Controller.SendUse(Target);
h.Items.InteractionState.EnterExamine();
h.Controller.ResetSession();
h.Controller.DrainOutbound();
h.Controller.OnNaturalMoveToComplete();
Assert.Null(h.Selection.SelectedObjectId);
Assert.False(h.Items.IsAnyTargetModeActive);
Assert.Empty(h.Transport.Pickups);
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void OldRemovalClearsCapturedActionButDoesNotClearReplacementSelection()
{
var h = new Harness();
h.SetApproach(closeRange: true, localEntityId: 101u);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.SendUse(Target);
var oldRecord = new LiveEntityRecord(Spawn(Target, instance: 1));
oldRecord.WorldEntity = new WorldEntity
{
Id = 101u,
ServerGuid = Target,
SourceGfxObjOrSetupId = 0x0200_0001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = [],
};
h.Controller.OnEntityRemoved(oldRecord, replacementExists: true);
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(Target, h.Selection.SelectedObjectId);
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void SynchronousNaturalCompletionObservesTheArmedCloseAction()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Movement.AfterArm = h.Controller.OnNaturalMoveToComplete;
h.Controller.SendUse(Target);
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
[Fact]
public void SynchronousResetCannotResurrectTheCloseAction()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Movement.AfterArm = h.Controller.ResetSession;
h.Controller.SendUse(Target);
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void CancelledClosePickupWithdrawsPresentationAndNeverFiresLater()
{
var h = new Harness();
h.SetApproach(closeRange: true);
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
Assert.Single(h.PendingPlacements);
Assert.Empty(h.Transport.Pickups);
h.Controller.OnMoveToCancelled(WeenieError.ActionCancelled);
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId));
Assert.Empty(h.Transport.Pickups);
}
[Fact]
public void ErrorCompletionClearsCloseActionBeforeALaterSuccess()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Controller.SendUse(Target);
h.Controller.OnMoveToCancelled(WeenieError.NoObject);
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Transport.Uses);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DeferredCompletionFromPriorApproachCannotAffectReplacement(
bool priorCompletedNaturally)
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Controller.SendUse(Target);
if (priorCompletedNaturally)
h.CompletionLifetime.PublishNaturalCompletion();
else
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
h.Controller.SendUse(Target);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
h.CompletionLifetime.PublishNaturalCompletion();
h.Controller.DrainOutbound();
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
[Fact]
public void RetiringControllerLifetimeInvalidatesQueuedArrival()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Controller.SendUse(Target);
h.CompletionLifetime.PublishNaturalCompletion();
h.Completions.RetireControllerLifetime(h.CompletionLifetime);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void FailedClosePickupStartWithdrawsPresentation()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Movement.Starts = false;
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
Assert.Single(h.PendingPlacements);
Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId));
Assert.Empty(h.Transport.Pickups);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void InvalidKeyboardPickupNeverPublishesAWaitingSlot(bool creature)
{
var h = new Harness();
h.Query.Creature = creature;
h.Query.Pickupable = false;
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.SelectionPickUp);
h.Controller.DrainOutbound();
Assert.Empty(h.PendingPlacements);
Assert.Empty(h.Transport.Pickups);
}
[Theory]
[InlineData(InputAction.SelectDblLeft)]
[InlineData(InputAction.UseSelected)]
[InlineData(InputAction.SelectionPickUp)]
public void QueuedWorldActionCannotCrossAnIncarnationBoundary(InputAction action)
{
var h = new Harness();
h.Query.Picked = Target;
h.SetApproach(closeRange: false);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(action);
h.Query.Current = false;
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
Assert.Empty(h.Transport.Pickups);
Assert.Empty(h.PendingPlacements);
}
[Fact]
public void DragReleasePulsesTheDropTargetWithoutChangingSelection()
{
var h = new Harness();
h.Query.Picked = Target;
const uint item = 0x7000_0002u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = item,
Name = "Dragged",
Type = ItemType.Misc,
});
var payload = new ItemDragPayload(
item,
ItemDragSource.Inventory,
SourceSlot: 0,
new UiItemSlot());
h.Controller.PlaceDraggedItem(payload, 10f, 20f);
Assert.Contains("pulse", h.Query.Events);
Assert.Null(h.Selection.SelectedObjectId);
}
[Fact]
public void RejectedTargetModeClickConsumesTheFollowingDoubleClick()
{
var h = new Harness();
h.Query.Picked = Target;
h.Objects.Remove(Target);
h.Items.InteractionState.EnterUse();
h.Controller.HandleInputAction(InputAction.SelectLeft);
h.Controller.HandleInputAction(InputAction.SelectDblLeft);
h.Controller.DrainOutbound();
Assert.Null(h.Selection.SelectedObjectId);
Assert.Empty(h.Transport.Uses);
Assert.Empty(h.Transport.Pickups);
}
[Fact]
public void TransportLossAtClosePickupCompletionWithdrawsPresentation()
{
var h = new Harness();
h.SetApproach(closeRange: true);
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
h.Transport.IsInWorld = false;
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId));
Assert.Empty(h.Transport.Pickups);
}
[Fact]
public void RepeatedPickupKeepsTheOriginalReservationAndDeferredAction()
{
var h = new Harness();
h.SetApproach(closeRange: true);
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.SelectionPickUp);
h.Controller.DrainOutbound();
Assert.Single(h.PendingPlacements);
Assert.Empty(h.CancelledPlacements);
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups);
}
[Fact]
public void WithdrawnPickupReservationInvalidatesDeferredWireAction()
{
const uint replacement = 0x7000_0002u;
var h = new Harness();
h.SetApproach(closeRange: true);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = replacement,
Name = "Replacement loot",
Type = ItemType.Misc,
});
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
Assert.Empty(h.Transport.Pickups);
PendingBackpackPlacement original = Assert.Single(h.PendingPlacements);
h.Items.CancelPendingBackpackPlacement(Target, original.Token);
Assert.True(h.Items.TryBeginPendingBackpackPlacement(
replacement,
Player,
placement: 0,
out _));
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Transport.Pickups);
Assert.True(h.Items.TryGetPendingBackpackPlacement(replacement, out _));
Assert.False(h.Items.TryGetPendingBackpackPlacement(Target, out _));
}
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
=> new(
guid,
Position: null,
SetupTableId: null,
AnimPartChanges: [],
TextureChanges: [],
SubPalettes: [],
BasePaletteId: null,
ObjScale: null,
Name: null,
ItemType: null,
MotionState: null,
MotionTableId: null,
InstanceSequence: instance);
}