Move local teleport, player-mode, animation, shadow, and sealed-dungeon lifetimes out of GameWindow. Make player-mode entry transactional and isolate approach completions by controller lifetime and approach generation so stale callbacks cannot affect replacements. Co-authored-by: Codex <noreply@openai.com>
607 lines
20 KiB
C#
607 lines
20 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 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.Misc,
|
|
});
|
|
Items = new ItemInteractionController(
|
|
Objects,
|
|
() => Player,
|
|
sendUse: guid => controller!.SendUse(guid),
|
|
sendUseWithTarget: null,
|
|
sendWield: null,
|
|
sendDrop: null,
|
|
sendExamine: guid =>
|
|
{
|
|
Query.Events.Add("examine");
|
|
Examines.Add(guid);
|
|
},
|
|
placeInBackpack: (item, container, placement) =>
|
|
controller!.SendPickup(item, container, placement));
|
|
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 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);
|
|
}
|