using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank.Tests; public sealed class NavigationTests { [Theory] [InlineData(0d, 1d, 0f)] [InlineData(1d, 0d, 90f)] [InlineData(0d, -1d, 180f)] [InlineData(-1d, 0d, 270f)] public void DesiredHeadingUsesVtankCompassConvention( double eastWest, double northSouth, float expected) { PluginNavigationPosition origin = Position(0d, 0d); PluginNavigationPosition target = Position(eastWest, northSouth); Assert.Equal(expected, NavigationController.DesiredHeading(origin, target)); } [Theory] [InlineData(350f, 10f, 20f)] [InlineData(10f, 350f, -20f)] [InlineData(90f, 270f, 180f)] public void SignedHeadingDeltaChoosesShortestRetailTurn( float current, float desired, float expected) => Assert.Equal(expected, NavigationController.SignedHeadingDelta(current, desired)); [Fact] public void PointSteeringTurnsInPlaceOutsideFortyFiveDegrees() { var automation = new FakeAutomation { NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 0f)), }; NavigationController controller = Controller( automation, RouteMode.Circular, Waypoint(RouteWaypointType.Point, Position(1d, 0d))); Assert.True(controller.Tick(0.05d, canAct: true)); PluginMovementIntent intent = Assert.Single(automation.Intents); Assert.False(intent.Forward); Assert.True(intent.TurnRight); Assert.False(intent.TurnLeft); } [Fact] public void PointSteeringMovesWhileTurningInsideFarFortyFiveDegreeCone() { var automation = new FakeAutomation { NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 60f)), }; NavigationController controller = Controller( automation, RouteMode.Circular, Waypoint(RouteWaypointType.Point, Position(1d, 0d))); Assert.True(controller.Tick(0.05d, canAct: true)); PluginMovementIntent intent = Assert.Single(automation.Intents); Assert.True(intent.Forward); Assert.True(intent.TurnRight); } [Fact] public void CircularRouteWrapsAndOnceRouteStops() { RouteWaypoint first = Waypoint(RouteWaypointType.Point, Position(0d, 0d)); RouteWaypoint second = Waypoint(RouteWaypointType.Point, Position(1d, 0d)); var circularAutomation = new FakeAutomation { NavigationSnapshot = Snapshot(first.Position), }; NavigationController circular = Controller( circularAutomation, RouteMode.Circular, first, second); Assert.True(circular.Tick(0.05d, canAct: true)); Assert.Equal(1, circular.CurrentWaypointIndex); circularAutomation.NavigationSnapshot = Snapshot(second.Position); Assert.True(circular.Tick(0.05d, canAct: true)); Assert.Equal(0, circular.CurrentWaypointIndex); var onceAutomation = new FakeAutomation { NavigationSnapshot = Snapshot(first.Position), }; NavigationController once = Controller( onceAutomation, RouteMode.Once, first); Assert.True(once.Tick(0.05d, canAct: true)); Assert.False(once.Tick(0.05d, canAct: true)); Assert.Equal("Once route complete.", once.Status); } [Fact] public void FollowReadsMovingTargetAndHoldsAtMinimumDistance() { var automation = new FakeAutomation { NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)), }; automation.Objects[7u] = new PluginNavigationObject( 7u, "Leader", Position(1d, 0d)); var settings = new NavigationSettings { Enabled = true, Mode = RouteMode.Target, MinimumDistanceMeters = 2d, FollowTargetObjectId = 7u, }; var controller = new NavigationController(new FakeHost(automation), settings); Assert.True(controller.Tick(0.05d, canAct: true)); Assert.True(Assert.Single(automation.Intents).Forward); automation.Objects[7u] = new PluginNavigationObject( 7u, "Leader", Position(0.005d, 0d)); Assert.False(controller.Tick(0.05d, canAct: true)); Assert.Equal(1, automation.ClearCount); Assert.Contains("holding", controller.Status, StringComparison.OrdinalIgnoreCase); } [Fact] public void FollowAroundCornersUsesOldestUnreachedBreadcrumb() { var automation = new FakeAutomation { NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)), }; automation.Objects[7u] = new PluginNavigationObject( 7u, "Leader", Position(0.1d, 0d)); var settings = new NavigationSettings { Enabled = true, Mode = RouteMode.Target, MinimumDistanceMeters = 2d, FollowTargetObjectId = 7u, FollowAroundCorners = true, }; var controller = new NavigationController(new FakeHost(automation), settings); Assert.True(controller.Tick(0.05d, canAct: true)); automation.Objects[7u] = new PluginNavigationObject( 7u, "Leader", Position(0.1d, 0.1d)); automation.Intents.Clear(); Assert.True(controller.Tick(0.05d, canAct: true)); PluginMovementIntent intent = Assert.Single(automation.Intents); Assert.True(intent.Forward); Assert.False(intent.TurnLeft); Assert.False(intent.TurnRight); } [Fact] public void CheckpointWaitsForServerAcceptedPosition() { PluginNavigationPosition point = Position(0d, 0d); var automation = new FakeAutomation { NavigationSnapshot = Snapshot(point) with { ConfirmedPosition = Position(0.1d, 0d), ConfirmedPositionRevision = 4UL, }, }; NavigationController controller = Controller( automation, RouteMode.Once, Waypoint(RouteWaypointType.Checkpoint, point)); Assert.True(controller.Tick(1d, canAct: true)); Assert.Contains("waiting for server", controller.Status, StringComparison.OrdinalIgnoreCase); Assert.True(controller.Tick(14d, canAct: true)); Assert.True(Assert.Single(automation.Intents).Forward); automation.NavigationSnapshot = Snapshot(point) with { ConfirmedPosition = point, ConfirmedPositionRevision = 5UL, }; Assert.True(controller.Tick(0.05d, canAct: true)); Assert.False(controller.Tick(0.05d, canAct: true)); } [Fact] public void ClosedDoorPausesRouteAndUsesCanonicalItemAction() { var automation = new FakeAutomation { NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)), }; automation.WorldObjects.Add(new PluginNavigationObject( 55u, "Dungeon Door", Position(0.01d, 0d)) { IsDoor = true, IsOpen = false, HasLockState = true, }); var settings = new NavigationSettings { Enabled = true, OpenDoors = true, Mode = RouteMode.Circular, }; settings.Waypoints.Add(Waypoint( RouteWaypointType.Point, Position(1d, 0d))); var controller = new NavigationController(new FakeHost(automation), settings); Assert.True(controller.Tick(0.05d, canAct: true)); Assert.Equal([55u], automation.UsedObjects); Assert.Empty(automation.Intents); automation.WorldObjects[0] = automation.WorldObjects[0] with { IsOpen = true, }; Assert.True(controller.Tick(0.05d, canAct: true)); Assert.True(Assert.Single(automation.Intents).Forward); } [Fact] public void PauseAndChatActionsObserveOfficialInitialDelay() { var automation = new FakeAutomation { NavigationSnapshot = Snapshot(Position(0d, 0d)), }; RouteWaypoint pause = Waypoint(RouteWaypointType.Pause, Position(0d, 0d)); pause.DurationMilliseconds = 100; RouteWaypoint chat = Waypoint(RouteWaypointType.ChatCommand, Position(0d, 0d)); chat.Text = "/say route"; NavigationController controller = Controller( automation, RouteMode.Once, pause, chat); Assert.True(controller.Tick(0.05d, canAct: true)); Assert.True(controller.Tick(0.05d, canAct: true)); Assert.Equal(0, controller.CurrentWaypointIndex); Assert.True(controller.Tick(0.19d, canAct: true)); Assert.Empty(automation.SubmittedChat); Assert.True(controller.Tick(0.01d, canAct: true)); Assert.Equal(["/say route"], automation.SubmittedChat); Assert.False(controller.Tick(0.01d, canAct: true)); } [Fact] public void PortalWaypointWaitsForPortalExitRatherThanUseDispatch() { var automation = new FakeAutomation { NavigationSnapshot = Snapshot(Position(0d, 0d)), ItemCompletion = new PluginItemUseCompletion(4, 10u, 0u, 0u), }; RouteWaypoint use = Waypoint(RouteWaypointType.Portal, Position(0d, 0d)); use.ObjectId = 77u; use.ObjectName = "Town Crier"; NavigationController controller = Controller(automation, RouteMode.Once, use); Assert.True(controller.Tick(0.05d, canAct: true)); Assert.Equal([77u], automation.UsedObjects); Assert.True(controller.Tick(0.05d, canAct: true)); automation.ItemCompletion = new PluginItemUseCompletion(5, 77u, 0u, 0u); Assert.True(controller.Tick(0.05d, canAct: true)); automation.NavigationSnapshot = automation.NavigationSnapshot with { IsPortalSpace = true, }; Assert.True(controller.Tick(0.05d, canAct: true)); automation.NavigationSnapshot = Snapshot(Position(0.1d, 0d)); Assert.True(controller.Tick(0.05d, canAct: true)); Assert.False(controller.Tick(0.05d, canAct: true)); } [Fact] public void UseNpcRepeatsUntilTheNpcRespondsInChat() { var automation = new FakeAutomation { NavigationSnapshot = Snapshot(Position(0d, 0d)), FoundObject = new PluginNavigationObject( 91u, "Town Crier", Position(0.01d, 0d)), }; RouteWaypoint use = Waypoint(RouteWaypointType.UseNpc, Position(0d, 0d)); use.ObjectName = "Town Crier"; NavigationController controller = Controller(automation, RouteMode.Once, use); Assert.True(controller.Tick(0.05d, canAct: true)); automation.ChatMessages.Add(new PluginChatMessage( 1UL, 91u, 3, "Town Crier", "Town Crier tells you, Welcome.", string.Empty)); Assert.True(controller.Tick(0.05d, canAct: true)); Assert.False(controller.Tick(0.05d, canAct: true)); } [Fact] public void NamedNpcWaypointReacquiresChangedObjectId() { var automation = new FakeAutomation { NavigationSnapshot = Snapshot(Position(0d, 0d)), FoundObject = new PluginNavigationObject( 91u, "Town Crier", Position(0.01d, 0d)), }; RouteWaypoint use = Waypoint(RouteWaypointType.UseNpc, Position(0d, 0d)); use.ObjectId = 77u; use.ObjectName = "Town Crier"; NavigationController controller = Controller(automation, RouteMode.Once, use); Assert.True(controller.Tick(0.05d, canAct: true)); Assert.Equal(91u, use.ObjectId); Assert.Equal([91u], automation.UsedObjects); Assert.Equal("Town Crier", automation.FindName); } [Fact] public void JumpAlignsBeforeChargingAndWaitsForLanding() { var automation = new FakeAutomation { NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 0f)), }; RouteWaypoint jump = Waypoint(RouteWaypointType.Jump, Position(0d, 0d)); jump.JumpHeadingDegrees = 90f; jump.JumpChargeMilliseconds = 100; NavigationController controller = Controller(automation, RouteMode.Once, jump); Assert.True(controller.Tick(0.05d, canAct: true)); PluginMovementIntent turn = Assert.Single(automation.Intents); Assert.True(turn.TurnRight); Assert.False(turn.Jump); automation.NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)); Assert.True(controller.Tick(0.05d, canAct: true)); Assert.True(automation.Intents[^1].Jump); Assert.True(controller.Tick(0.05d, canAct: true)); Assert.False(automation.Intents[^1].Jump); automation.NavigationSnapshot = Snapshot( Position(0d, 0d, heading: 90f), airborne: true); Assert.True(controller.Tick(0.05d, canAct: true)); automation.NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)); Assert.True(controller.Tick(0.25d, canAct: true)); Assert.False(controller.Tick(0.01d, canAct: true)); } [Fact] public void RouteProfilesRoundTripEveryWaypointField() { var storage = new MemoryStorage(); var host = new FakeHost(new FakeAutomation(), storage); var source = new NavigationSettings { Enabled = true, Priority = true, Mode = RouteMode.Linear, MinimumDistanceMeters = 4.5d, FollowTargetObjectId = 99u, FollowTargetName = "Leader", FollowAroundCorners = false, OpenDoors = true, DoorIdentifyRangeMeters = 35d, DoorOpenRangeMeters = 3.5d, DoorLockpickExcessThreshold = 17, }; source.Waypoints.Add(new RouteWaypoint { Type = RouteWaypointType.Jump, Position = new PluginNavigationPosition(0x7F7F0001u, 1.2d, -3.4d, 5.6d, 78f, true), ObjectId = 88u, ObjectName = "Portal", Text = "/say hello", DurationMilliseconds = 1234, Recall = RouteRecallKind.SecondaryPortal, JumpHeadingDegrees = 271.5f, JumpRun = true, JumpChargeMilliseconds = 875, JumpDirection = RouteJumpDirection.StrafeRight, }); var first = new MossTankRouteProfileStore(host); Assert.True(first.BindCharacter("Test Character")); first.SaveCurrent(source); var target = new NavigationSettings(); var second = new MossTankRouteProfileStore(host); Assert.True(second.BindCharacter("Test Character")); Assert.True(second.LoadCurrent(target)); Assert.True(target.Enabled); Assert.True(target.Priority); Assert.Equal(RouteMode.Linear, target.Mode); Assert.Equal(4.5d, target.MinimumDistanceMeters); Assert.Equal(99u, target.FollowTargetObjectId); Assert.False(target.FollowAroundCorners); Assert.True(target.OpenDoors); Assert.Equal(35d, target.DoorIdentifyRangeMeters); Assert.Equal(3.5d, target.DoorOpenRangeMeters); Assert.Equal(17, target.DoorLockpickExcessThreshold); RouteWaypoint waypoint = Assert.Single(target.Waypoints); Assert.Equal(RouteWaypointType.Jump, waypoint.Type); Assert.Equal(271.5f, waypoint.JumpHeadingDegrees); Assert.True(waypoint.JumpRun); Assert.Equal(875, waypoint.JumpChargeMilliseconds); Assert.Equal(RouteJumpDirection.StrafeRight, waypoint.JumpDirection); Assert.Equal(0x7F7F0001u, waypoint.Position.CellId); } private static NavigationController Controller( FakeAutomation automation, RouteMode mode, params RouteWaypoint[] waypoints) { var settings = new NavigationSettings { Enabled = true, Mode = mode, MinimumDistanceMeters = 2d, }; settings.Waypoints.AddRange(waypoints); return new NavigationController(new FakeHost(automation), settings); } private static RouteWaypoint Waypoint( RouteWaypointType type, PluginNavigationPosition position) => new() { Type = type, Position = position, }; private static PluginNavigationSnapshot Snapshot( PluginNavigationPosition position, bool airborne = false) => new( IsAvailable: true, IsPortalSpace: false, LocalObjectId: 1u, position, IsMoving: false, IsAirborne: airborne); private static PluginNavigationPosition Position( double eastWest, double northSouth, float heading = 0f) => new( 0x7F7F0001u, eastWest, northSouth, 0d, heading, IsOutdoor: true); private sealed class FakeHost( FakeAutomation automation, IPluginStorage? storage = null) : IPluginHost { public bool HasUi => false; public IPluginLogger Log { get; } = new FakeLogger(); public IGameState State { get; } = new FakeState(); public IEvents Events { get; } = new FakeEvents(); public ISelectionService Selection { get; } = new FakeSelection(); public IUiRegistry Ui => NoOpUiRegistry.Instance; public IPluginStorage Storage { get; } = storage ?? NoOpPluginStorage.Instance; public IAutomationSurface Automation { get; } = automation; } private sealed class FakeAutomation : IAutomationSurface, INavigationAutomation, IPluginChat, IItemAutomation { public bool IsAvailable => true; public ICharacterInfo Character => NoOpAutomationSurface.Instance; public ISpellCatalog Spells => NoOpAutomationSurface.Instance; public IMagicCommands Magic => NoOpAutomationSurface.Instance; public IPluginChat Chat => this; public IItemAutomation Items => this; public INavigationAutomation Navigation => this; public PluginNavigationSnapshot NavigationSnapshot { get; set; } PluginNavigationSnapshot INavigationAutomation.Snapshot => NavigationSnapshot; public Dictionary Objects { get; } = []; public List WorldObjects { get; } = []; public List Intents { get; } = []; public List SubmittedChat { get; } = []; public List ChatMessages { get; } = []; public List UsedObjects { get; } = []; public int ClearCount { get; private set; } public PluginItemUseCompletion ItemCompletion { get; set; } public PluginItemUseCompletion LastCompletion => ItemCompletion; public PluginNavigationObject? FoundObject { get; set; } public string? FindName { get; private set; } public bool TryGetObject(uint objectId, out PluginNavigationObject value) => Objects.TryGetValue(objectId, out value); public bool TryFindObject( string name, in PluginNavigationPosition near, double maximumDistanceMeters, out PluginNavigationObject value) { FindName = name; value = FoundObject ?? default; return FoundObject.HasValue; } public IReadOnlyList CaptureObjects() => WorldObjects; public PluginNavigationCommandStatus SetMovementIntent( in PluginMovementIntent intent) { Intents.Add(intent); return PluginNavigationCommandStatus.Accepted; } public PluginNavigationCommandStatus ClearMovementIntent() { ClearCount++; return PluginNavigationCommandStatus.Accepted; } public bool Submit(string text) { SubmittedChat.Add(text); return true; } public IReadOnlyList CaptureMessages(ulong afterSequence) => ChatMessages.Where(message => message.Sequence > afterSequence).ToArray(); public void PostSystemMessage(string text) { } public PluginItemCommandResult Use(uint objectId) { UsedObjects.Add(objectId); return new PluginItemCommandResult(PluginItemCommandStatus.Started); } } private sealed class MemoryStorage : IPluginStorage { private readonly Dictionary _text = new(StringComparer.Ordinal); public bool IsAvailable => true; public string? ReadText(string key) => _text.TryGetValue(key, out string? value) ? value : null; public void WriteText(string key, string content) => _text[key] = content; public bool Delete(string key) => _text.Remove(key); } private sealed class FakeLogger : IPluginLogger { public void Info(string message) { } public void Warn(string message) { } public void Error(string message, Exception? exception = null) { } } private sealed class FakeState : IGameState { public IReadOnlyList Entities => []; } private sealed class FakeEvents : IEvents { public event Action EntitySpawned { add { } remove { } } public event Action Tick { add { } remove { } } } private sealed class FakeSelection : ISelectionService { public uint? SelectedObjectId => null; public uint? PreviousObjectId => null; public event Action Changed { add { } remove { } } public bool Select(uint objectId) => false; public bool Clear() => false; } }