Reorganize namespaces; fix steps with 'fly: true' not mounting if close to target
authorLiza Carvelli <liza@carvel.li>
Fri, 12 Jul 2024 19:18:53 +0000 (21:18 +0200)
committerLiza Carvelli <liza@carvel.li>
Fri, 12 Jul 2024 19:19:44 +0000 (21:19 +0200)
55 files changed:
Questionable/Controller/QuestController.cs
Questionable/Controller/Steps/BaseFactory/AethernetShortcut.cs [deleted file]
Questionable/Controller/Steps/BaseFactory/AetheryteShortcut.cs [deleted file]
Questionable/Controller/Steps/BaseFactory/Move.cs [deleted file]
Questionable/Controller/Steps/BaseFactory/SkipCondition.cs [deleted file]
Questionable/Controller/Steps/BaseFactory/StepDisabled.cs [deleted file]
Questionable/Controller/Steps/BaseFactory/WaitAtEnd.cs [deleted file]
Questionable/Controller/Steps/BaseFactory/WaitAtStart.cs [deleted file]
Questionable/Controller/Steps/BaseTasks/AbstractDelayedTask.cs [deleted file]
Questionable/Controller/Steps/BaseTasks/MountTask.cs [deleted file]
Questionable/Controller/Steps/BaseTasks/NextQuest.cs [deleted file]
Questionable/Controller/Steps/BaseTasks/UnmountTask.cs [deleted file]
Questionable/Controller/Steps/BaseTasks/WaitConditionTask.cs [deleted file]
Questionable/Controller/Steps/Common/AbstractDelayedTask.cs [new file with mode: 0644]
Questionable/Controller/Steps/Common/MountTask.cs [new file with mode: 0644]
Questionable/Controller/Steps/Common/NextQuest.cs [new file with mode: 0644]
Questionable/Controller/Steps/Common/UnmountTask.cs [new file with mode: 0644]
Questionable/Controller/Steps/Common/WaitConditionTask.cs [new file with mode: 0644]
Questionable/Controller/Steps/InteractionFactory/Action.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/AetherCurrent.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/AethernetShard.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/Aetheryte.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/Combat.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/Duty.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/Emote.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/EquipItem.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/Interact.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/Jump.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/Say.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/SinglePlayerDuty.cs [deleted file]
Questionable/Controller/Steps/InteractionFactory/UseItem.cs [deleted file]
Questionable/Controller/Steps/Interactions/Action.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/AetherCurrent.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/AethernetShard.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/Aetheryte.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/Combat.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/Duty.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/Emote.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/EquipItem.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/Interact.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/Jump.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/Say.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs [new file with mode: 0644]
Questionable/Controller/Steps/Interactions/UseItem.cs [new file with mode: 0644]
Questionable/Controller/Steps/Shared/AethernetShortcut.cs [new file with mode: 0644]
Questionable/Controller/Steps/Shared/AetheryteShortcut.cs [new file with mode: 0644]
Questionable/Controller/Steps/Shared/Move.cs [new file with mode: 0644]
Questionable/Controller/Steps/Shared/SkipCondition.cs [new file with mode: 0644]
Questionable/Controller/Steps/Shared/StepDisabled.cs [new file with mode: 0644]
Questionable/Controller/Steps/Shared/WaitAtEnd.cs [new file with mode: 0644]
Questionable/Controller/Steps/Shared/WaitAtStart.cs [new file with mode: 0644]
Questionable/Questionable.json
Questionable/QuestionablePlugin.cs
Questionable/Windows/DebugOverlay.cs
Questionable/Windows/QuestWindow.cs

index e00ab5d327fbcf8bdfc65e76304b8e4e1cabb09d..00ba0a5e816e63cbf995c5135693b5c98396550d 100644 (file)
@@ -5,7 +5,7 @@ using Dalamud.Game.ClientState.Keys;
 using Dalamud.Plugin.Services;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller.Steps;
-using Questionable.Controller.Steps.BaseFactory;
+using Questionable.Controller.Steps.Shared;
 using Questionable.External;
 using Questionable.Model;
 using Questionable.Model.V1;
diff --git a/Questionable/Controller/Steps/BaseFactory/AethernetShortcut.cs b/Questionable/Controller/Steps/BaseFactory/AethernetShortcut.cs
deleted file mode 100644 (file)
index 5bcd634..0000000
+++ /dev/null
@@ -1,155 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Numerics;
-using Dalamud.Plugin.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Data;
-using Questionable.External;
-using Questionable.Model;
-using Questionable.Model.V1;
-using Questionable.Model.V1.Converter;
-
-namespace Questionable.Controller.Steps.BaseFactory;
-
-internal static class AethernetShortcut
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.AethernetShortcut == null)
-                return null;
-
-            return serviceProvider.GetRequiredService<UseAethernetShortcut>()
-                .With(step.AethernetShortcut.From, step.AethernetShortcut.To);
-        }
-    }
-
-    internal sealed class UseAethernetShortcut(
-        ILogger<UseAethernetShortcut> logger,
-        GameFunctions gameFunctions,
-        IClientState clientState,
-        AetheryteData aetheryteData,
-        LifestreamIpc lifestreamIpc,
-        MovementController movementController) : ISkippableTask
-    {
-        private bool _moving;
-        private bool _teleported;
-
-        public EAetheryteLocation From { get; set; }
-        public EAetheryteLocation To { get; set; }
-
-        public ITask With(EAetheryteLocation from, EAetheryteLocation to)
-        {
-            From = from;
-            To = to;
-            return this;
-        }
-
-        public bool Start()
-        {
-            if (gameFunctions.IsAetheryteUnlocked(From) &&
-                gameFunctions.IsAetheryteUnlocked(To))
-            {
-                ushort territoryType = clientState.TerritoryType;
-                Vector3 playerPosition = clientState.LocalPlayer!.Position;
-
-                // closer to the source
-                if (aetheryteData.CalculateDistance(playerPosition, territoryType, From) <
-                    aetheryteData.CalculateDistance(playerPosition, territoryType, To))
-                {
-                    if (aetheryteData.CalculateDistance(playerPosition, territoryType, From) < 11)
-                    {
-                        logger.LogInformation("Using lifestream to teleport to {Destination}", To);
-                        lifestreamIpc.Teleport(To);
-
-                        _teleported = true;
-                        return true;
-                    }
-                    else if (From == EAetheryteLocation.SolutionNine)
-                    {
-                        logger.LogInformation("Moving to S9 aetheryte");
-                        List<Vector3> nearbyPoints =
-                        [
-                            new(7.225532f, 8.467899f, -7.1670876f),
-                            new(7.177844f, 8.467899f, 7.2216787f),
-                            new(-7.0762224f, 8.467898f, 7.1924725f),
-                            new(-7.1289554f, 8.467898f, -7.0594683f)
-                        ];
-
-                        Vector3 closestPoint = nearbyPoints.MinBy(x => (playerPosition - x).Length());
-                        _moving = true;
-                        movementController.NavigateTo(EMovementType.Quest, (uint)From, closestPoint, false, true,
-                            0.25f);
-                        return true;
-                    }
-                    else
-                    {
-                        logger.LogInformation("Moving to aethernet shortcut");
-                        _moving = true;
-                        movementController.NavigateTo(EMovementType.Quest, (uint)From, aetheryteData.Locations[From],
-                            false, true,
-                            AetheryteConverter.IsLargeAetheryte(From) ? 10.9f : 6.9f);
-                        return true;
-                    }
-                }
-            }
-            else
-                logger.LogWarning(
-                    "Aethernet shortcut not unlocked (from: {FromAetheryte}, to: {ToAetheryte}), walking manually",
-                    From, To);
-
-            return false;
-        }
-
-        public ETaskResult Update()
-        {
-            if (_moving)
-            {
-                var movementStartedAt = movementController.MovementStartedAt;
-                if (movementStartedAt == DateTime.MaxValue || movementStartedAt.AddSeconds(2) >= DateTime.Now)
-                    return ETaskResult.StillRunning;
-
-                if (!movementController.IsPathfinding && !movementController.IsPathRunning)
-                    _moving = false;
-
-                return ETaskResult.StillRunning;
-            }
-
-            if (!_teleported)
-            {
-                logger.LogInformation("Using lifestream to teleport to {Destination}", To);
-                lifestreamIpc.Teleport(To);
-
-                _teleported = true;
-                return ETaskResult.StillRunning;
-            }
-
-            if (aetheryteData.IsAirshipLanding(To))
-            {
-                if (aetheryteData.CalculateAirshipLandingDistance(clientState.LocalPlayer?.Position ?? Vector3.Zero,
-                        clientState.TerritoryType, To) > 5)
-                    return ETaskResult.StillRunning;
-            }
-            else if (aetheryteData.IsCityAetheryte(To))
-            {
-                if (aetheryteData.CalculateDistance(clientState.LocalPlayer?.Position ?? Vector3.Zero,
-                        clientState.TerritoryType, To) > 20)
-                    return ETaskResult.StillRunning;
-            }
-            else
-            {
-                // some overworld location (e.g. 'Tesselation (Lakeland)' would end up here
-                if (clientState.TerritoryType != aetheryteData.TerritoryIds[To])
-                    return ETaskResult.StillRunning;
-            }
-
-
-            return ETaskResult.TaskComplete;
-        }
-
-        public override string ToString() => $"UseAethernet({From} -> {To})";
-    }
-}
diff --git a/Questionable/Controller/Steps/BaseFactory/AetheryteShortcut.cs b/Questionable/Controller/Steps/BaseFactory/AetheryteShortcut.cs
deleted file mode 100644 (file)
index 3542c88..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Numerics;
-using Dalamud.Plugin.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Data;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.BaseFactory;
-
-internal static class AetheryteShortcut
-{
-    internal sealed class Factory(
-        IServiceProvider serviceProvider,
-        GameFunctions gameFunctions,
-        AetheryteData aetheryteData) : ITaskFactory
-    {
-        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.AetheryteShortcut == null)
-                return [];
-
-            var task = serviceProvider.GetRequiredService<UseAetheryteShortcut>()
-                .With(step, step.AetheryteShortcut.Value, aetheryteData.TerritoryIds[step.AetheryteShortcut.Value]);
-            return
-            [
-                new WaitConditionTask(() => gameFunctions.CanTeleport(step.AetheryteShortcut.Value), "CanTeleport"),
-                task
-            ];
-        }
-
-        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-            => throw new InvalidOperationException();
-    }
-
-    internal sealed class UseAetheryteShortcut(
-        ILogger<UseAetheryteShortcut> logger,
-        GameFunctions gameFunctions,
-        IClientState clientState,
-        IChatGui chatGui,
-        AetheryteData aetheryteData) : ISkippableTask
-    {
-        private DateTime _continueAt;
-
-        public QuestStep Step { get; set; } = null!;
-        public EAetheryteLocation TargetAetheryte { get; set; }
-
-        /// <summary>
-        /// If using an aethernet shortcut after, the aetheryte's territory-id and the step's territory-id can differ,
-        /// we always use the aetheryte's territory-id.
-        /// </summary>
-        public ushort ExpectedTerritoryId { get; set; }
-
-        public ITask With(QuestStep step, EAetheryteLocation targetAetheryte, ushort expectedTerritoryId)
-        {
-            Step = step;
-            TargetAetheryte = targetAetheryte;
-            ExpectedTerritoryId = expectedTerritoryId;
-            return this;
-        }
-
-        public bool Start()
-        {
-            _continueAt = DateTime.Now.AddSeconds(8);
-            ushort territoryType = clientState.TerritoryType;
-            if (ExpectedTerritoryId == territoryType)
-            {
-                if (Step.SkipIf.Contains(ESkipCondition.AetheryteShortcutIfInSameTerritory))
-                {
-                    logger.LogInformation("Skipping aetheryte teleport due to SkipIf");
-                    return false;
-                }
-
-                Vector3 pos = clientState.LocalPlayer!.Position;
-                if (Step.Position != null && (pos - Step.Position.Value).Length() < Step.CalculateActualStopDistance())
-                {
-                    logger.LogInformation("Skipping aetheryte teleport, we're near the target");
-                    return false;
-                }
-
-                if (aetheryteData.CalculateDistance(pos, territoryType, TargetAetheryte) < 20 ||
-                    (Step.AethernetShortcut != null &&
-                     (aetheryteData.CalculateDistance(pos, territoryType, Step.AethernetShortcut.From) < 20 ||
-                      aetheryteData.CalculateDistance(pos, territoryType, Step.AethernetShortcut.To) < 20)))
-                {
-                    logger.LogInformation("Skipping aetheryte teleport");
-                    return false;
-                }
-            }
-
-            if (!gameFunctions.IsAetheryteUnlocked(TargetAetheryte))
-            {
-                chatGui.Print($"[Questionable] Aetheryte {TargetAetheryte} is not unlocked.");
-                throw new TaskException("Aetheryte is not unlocked");
-            }
-            else if (gameFunctions.TeleportAetheryte(TargetAetheryte))
-            {
-                logger.LogInformation("Travelling via aetheryte...");
-                return true;
-            }
-            else
-            {
-                chatGui.Print("[Questionable] Unable to teleport to aetheryte.");
-                throw new TaskException("Unable to teleport to aetheryte");
-            }
-        }
-
-        public ETaskResult Update()
-        {
-            if (DateTime.Now >= _continueAt && clientState.TerritoryType == ExpectedTerritoryId)
-                return ETaskResult.TaskComplete;
-
-            return ETaskResult.StillRunning;
-        }
-
-        public override string ToString() => $"UseAetheryte({TargetAetheryte})";
-    }
-}
diff --git a/Questionable/Controller/Steps/BaseFactory/Move.cs b/Questionable/Controller/Steps/BaseFactory/Move.cs
deleted file mode 100644 (file)
index 6633c48..0000000
+++ /dev/null
@@ -1,171 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Numerics;
-using Dalamud.Game.ClientState.Objects.Types;
-using Dalamud.Plugin.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Data;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.BaseFactory;
-
-internal static class Move
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.Position != null)
-            {
-                var builder = serviceProvider.GetRequiredService<MoveBuilder>();
-                builder.Step = step;
-                builder.Destination = step.Position.Value;
-                return builder.Build();
-            }
-            else if (step is { DataId: not null, StopDistance: not null })
-            {
-                var task = serviceProvider.GetRequiredService<ExpectToBeNearDataId>();
-                task.DataId = step.DataId.Value;
-                task.StopDistance = step.StopDistance.Value;
-                return [task];
-            }
-
-            return [];
-        }
-
-        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-            => throw new InvalidOperationException();
-    }
-
-    internal sealed class MoveBuilder(
-        IServiceProvider serviceProvider,
-        ILogger<MoveBuilder> logger,
-        GameFunctions gameFunctions,
-        IClientState clientState,
-        MovementController movementController,
-        TerritoryData territoryData)
-    {
-        public QuestStep Step { get; set; } = null!;
-        public Vector3 Destination { get; set; }
-
-        public IEnumerable<ITask> Build()
-        {
-            if (Step.InteractionType == EInteractionType.Jump && Step.JumpDestination != null &&
-                (clientState.LocalPlayer!.Position - Step.JumpDestination.Position).Length() <=
-                (Step.JumpDestination.StopDistance ?? 1f))
-            {
-                logger.LogInformation("We're at the jump destination, skipping movement");
-                yield break;
-            }
-
-            yield return new WaitConditionTask(() => clientState.TerritoryType == Step.TerritoryId,
-                $"Wait(territory: {territoryData.GetNameAndId(Step.TerritoryId)})");
-
-            if (!Step.DisableNavmesh)
-                yield return new WaitConditionTask(() => movementController.IsNavmeshReady,
-                    "Wait(navmesh ready)");
-
-            float distance = Step.CalculateActualStopDistance();
-            var position = clientState.LocalPlayer?.Position ?? new Vector3();
-            float actualDistance = (position - Destination).Length();
-
-            if (Step.Mount == true)
-                yield return serviceProvider.GetRequiredService<MountTask>()
-                    .With(Step.TerritoryId, MountTask.EMountIf.Always);
-            else if (Step.Mount == false)
-                yield return serviceProvider.GetRequiredService<UnmountTask>();
-
-            if (!Step.DisableNavmesh)
-            {
-                if (Step.Mount == null)
-                    yield return serviceProvider.GetRequiredService<MountTask>()
-                        .With(Step.TerritoryId, MountTask.EMountIf.AwayFromPosition, Destination);
-
-                if (actualDistance > distance)
-                {
-                    yield return serviceProvider.GetRequiredService<MoveInternal>()
-                        .With(Destination, m =>
-                        {
-                            m.NavigateTo(EMovementType.Quest, Step.DataId, Destination,
-                                fly: Step.Fly == true && gameFunctions.IsFlyingUnlocked(Step.TerritoryId),
-                                sprint: Step.Sprint != false,
-                                stopDistance: distance);
-                        });
-                }
-            }
-            else
-            {
-                // navmesh won't move close enough
-                if (actualDistance > distance)
-                {
-                    yield return serviceProvider.GetRequiredService<MoveInternal>()
-                        .With(Destination, m =>
-                        {
-                            m.NavigateTo(EMovementType.Quest, Step.DataId, [Destination],
-                                fly: Step.Fly == true && gameFunctions.IsFlyingUnlockedInCurrentZone(),
-                                sprint: Step.Sprint != false,
-                                stopDistance: distance);
-                        });
-                }
-            }
-        }
-    }
-
-    internal sealed class MoveInternal(MovementController movementController, ILogger<MoveInternal> logger) : ITask
-    {
-        public Action<MovementController> StartAction { get; set; } = null!;
-        public Vector3 Destination { get; set; }
-
-        public ITask With(Vector3 destination, Action<MovementController> startAction)
-        {
-            Destination = destination;
-            StartAction = startAction;
-            return this;
-        }
-
-        public bool Start()
-        {
-            logger.LogInformation("Moving to {Destination}", Destination.ToString("G", CultureInfo.InvariantCulture));
-            StartAction(movementController);
-            return true;
-        }
-
-        public ETaskResult Update()
-        {
-            if (movementController.IsPathfinding || movementController.IsPathRunning)
-                return ETaskResult.StillRunning;
-
-            DateTime movementStartedAt = movementController.MovementStartedAt;
-            if (movementStartedAt == DateTime.MaxValue || movementStartedAt.AddSeconds(2) >= DateTime.Now)
-                return ETaskResult.StillRunning;
-
-            return ETaskResult.TaskComplete;
-        }
-
-        public override string ToString() => $"MoveTo({Destination.ToString("G", CultureInfo.InvariantCulture)})";
-    }
-
-    internal sealed class ExpectToBeNearDataId(GameFunctions gameFunctions, IClientState clientState) : ITask
-    {
-        public uint DataId { get; set; }
-        public float StopDistance { get; set; }
-
-        public bool Start() => true;
-
-        public ETaskResult Update()
-        {
-            IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
-            if (gameObject == null ||
-                (gameObject.Position - clientState.LocalPlayer!.Position).Length() > StopDistance)
-            {
-                throw new TaskException("Object not found or too far away, no position so we can't move");
-            }
-
-            return ETaskResult.TaskComplete;
-        }
-    }
-}
diff --git a/Questionable/Controller/Steps/BaseFactory/SkipCondition.cs b/Questionable/Controller/Steps/BaseFactory/SkipCondition.cs
deleted file mode 100644 (file)
index d31f46e..0000000
+++ /dev/null
@@ -1,128 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Dalamud.Game.ClientState.Objects.Types;
-using Dalamud.Plugin.Services;
-using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
-using FFXIVClientStructs.FFXIV.Client.Game.UI;
-using FFXIVClientStructs.FFXIV.Client.System.Framework;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.BaseFactory;
-
-internal static class SkipCondition
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.SkipIf.Contains(ESkipCondition.Never))
-                return null;
-
-            var relevantConditions =
-                step.SkipIf.Where(x => x != ESkipCondition.AetheryteShortcutIfInSameTerritory).ToList();
-            if (relevantConditions.Count == 0 && step.CompletionQuestVariablesFlags.Count == 0)
-                return null;
-
-            return serviceProvider.GetRequiredService<CheckTask>()
-                .With(step, relevantConditions, quest.QuestId);
-        }
-    }
-
-    internal sealed class CheckTask(
-        ILogger<CheckTask> logger,
-        GameFunctions gameFunctions,
-        IClientState clientState) : ITask
-    {
-        public QuestStep Step { get; set; } = null!;
-        public List<ESkipCondition> SkipConditions { get; set; } = null!;
-        public ushort QuestId { get; set; }
-
-        public ITask With(QuestStep step, List<ESkipCondition> skipConditions, ushort questId)
-        {
-            Step = step;
-            SkipConditions = skipConditions;
-            QuestId = questId;
-            return this;
-        }
-
-        public unsafe bool Start()
-        {
-            logger.LogInformation("Checking skip conditions; {ConfiguredConditions}", string.Join(",", SkipConditions));
-
-            if (SkipConditions.Contains(ESkipCondition.FlyingUnlocked) &&
-                gameFunctions.IsFlyingUnlocked(Step.TerritoryId))
-            {
-                logger.LogInformation("Skipping step, as flying is unlocked");
-                return true;
-            }
-
-            if (SkipConditions.Contains(ESkipCondition.FlyingLocked) &&
-                !gameFunctions.IsFlyingUnlocked(Step.TerritoryId))
-            {
-                logger.LogInformation("Skipping step, as flying is locked");
-                return true;
-            }
-
-            if (SkipConditions.Contains(ESkipCondition.ChocoboUnlocked) &&
-                PlayerState.Instance()->IsMountUnlocked(1))
-            {
-                logger.LogInformation("Skipping step, as chocobo is unlocked");
-                return true;
-            }
-
-            if (SkipConditions.Contains(ESkipCondition.NotTargetable) &&
-                Step is { DataId: not null })
-            {
-                IGameObject? gameObject = gameFunctions.FindObjectByDataId(Step.DataId.Value);
-                if (gameObject == null)
-                {
-                    if ((Step.Position.GetValueOrDefault() - clientState.LocalPlayer!.Position).Length() < 100)
-                    {
-                        logger.LogInformation("Skipping step, object is not nearby (but we are)");
-                        return true;
-                    }
-                }
-                else if (!gameObject.IsTargetable)
-                {
-                    logger.LogInformation("Skipping step, object is not targetable");
-                    return true;
-                }
-            }
-
-            if (Step is
-                {
-                    DataId: not null,
-                    InteractionType: EInteractionType.AttuneAetheryte or EInteractionType.AttuneAethernetShard
-                } &&
-                gameFunctions.IsAetheryteUnlocked((EAetheryteLocation)Step.DataId.Value))
-            {
-                logger.LogInformation("Skipping step, as aetheryte/aethernet shard is unlocked");
-                return true;
-            }
-
-            if (Step is { DataId: not null, InteractionType: EInteractionType.AttuneAetherCurrent } &&
-                gameFunctions.IsAetherCurrentUnlocked(Step.DataId.Value))
-            {
-                logger.LogInformation("Skipping step, as current is unlocked");
-                return true;
-            }
-
-            QuestWork? questWork = gameFunctions.GetQuestEx(QuestId);
-            if (questWork != null && Step.MatchesQuestVariables(questWork.Value, true))
-            {
-                logger.LogInformation("Skipping step, as quest variables match");
-                return true;
-            }
-
-            return false;
-        }
-
-        public ETaskResult Update() => ETaskResult.SkipRemainingTasksForStep;
-
-        public override string ToString() => $"CheckSkip({string.Join(", ", SkipConditions)})";
-    }
-}
diff --git a/Questionable/Controller/Steps/BaseFactory/StepDisabled.cs b/Questionable/Controller/Steps/BaseFactory/StepDisabled.cs
deleted file mode 100644 (file)
index ecb367b..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-using System;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.BaseFactory;
-
-internal static class StepDisabled
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (!step.Disabled)
-                return null;
-
-            return serviceProvider.GetRequiredService<Task>();
-        }
-    }
-
-    internal sealed class Task(ILogger<Task> logger) : ITask
-    {
-        public bool Start() => true;
-
-        public ETaskResult Update()
-        {
-            logger.LogInformation("Skipping step, as it is disabled");
-            return ETaskResult.SkipRemainingTasksForStep;
-        }
-
-        public override string ToString() => "StepDisabled";
-    }
-}
diff --git a/Questionable/Controller/Steps/BaseFactory/WaitAtEnd.cs b/Questionable/Controller/Steps/BaseFactory/WaitAtEnd.cs
deleted file mode 100644 (file)
index 1147458..0000000
+++ /dev/null
@@ -1,274 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Numerics;
-using Dalamud.Game.ClientState.Conditions;
-using Dalamud.Plugin.Services;
-using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
-using FFXIVClientStructs.FFXIV.Client.Game;
-using Microsoft.Extensions.DependencyInjection;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Data;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.BaseFactory;
-
-internal static class WaitAtEnd
-{
-    internal sealed class Factory(IServiceProvider serviceProvider, IClientState clientState, ICondition condition,
-        TerritoryData territoryData)
-        : ITaskFactory
-    {
-        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.CompletionQuestVariablesFlags.Count == 6 && step.CompletionQuestVariablesFlags.Any(x => x is > 0))
-            {
-                var task = serviceProvider.GetRequiredService<WaitForCompletionFlags>()
-                    .With(quest, step);
-                var delay = serviceProvider.GetRequiredService<WaitDelay>();
-                return [task, delay, Next(quest, sequence)];
-            }
-
-            switch (step.InteractionType)
-            {
-                case EInteractionType.Combat:
-                    var notInCombat =
-                        new WaitConditionTask(() => !condition[ConditionFlag.InCombat], "Wait(not in combat)");
-                    return
-                    [
-                        serviceProvider.GetRequiredService<WaitDelay>(),
-                        notInCombat,
-                        serviceProvider.GetRequiredService<WaitDelay>(),
-                        Next(quest, sequence)
-                    ];
-
-                case EInteractionType.WaitForManualProgress:
-                case EInteractionType.ShouldBeAJump:
-                case EInteractionType.Instruction:
-                    return [serviceProvider.GetRequiredService<WaitNextStepOrSequence>()];
-
-                case EInteractionType.Duty:
-                case EInteractionType.SinglePlayerDuty:
-                    return [new EndAutomation()];
-
-                case EInteractionType.WalkTo:
-                case EInteractionType.Jump:
-                    // no need to wait if we're just moving around
-                    return [Next(quest, sequence)];
-
-                case EInteractionType.WaitForObjectAtPosition:
-                    ArgumentNullException.ThrowIfNull(step.DataId);
-                    ArgumentNullException.ThrowIfNull(step.Position);
-
-                    return
-                    [
-                        serviceProvider.GetRequiredService<WaitObjectAtPosition>()
-                            .With(step.DataId.Value, step.Position.Value, step.NpcWaitDistance ?? 0.05f),
-                        serviceProvider.GetRequiredService<WaitDelay>(),
-                        Next(quest, sequence)
-                    ];
-
-                case EInteractionType.Interact when step.TargetTerritoryId != null:
-                case EInteractionType.UseItem when step.TargetTerritoryId != null:
-                    ITask waitInteraction;
-                    if (step.TerritoryId != step.TargetTerritoryId)
-                    {
-                        // interaction moves to a different territory
-                        waitInteraction = new WaitConditionTask(
-                            () => clientState.TerritoryType == step.TargetTerritoryId,
-                            $"Wait(tp to territory: {territoryData.GetNameAndId(step.TargetTerritoryId.Value)})");
-                    }
-                    else
-                    {
-                        Vector3 lastPosition = step.Position ?? clientState.LocalPlayer?.Position ?? Vector3.Zero;
-                        waitInteraction = new WaitConditionTask(() =>
-                            {
-                                Vector3? currentPosition = clientState.LocalPlayer?.Position;
-                                if (currentPosition == null)
-                                    return false;
-
-                                // interaction moved to elsewhere in the zone
-                                // the 'closest' locations are probably
-                                //   - waking sands' solar
-                                //   - rising stones' solar + dawn's respite
-                                return (lastPosition - currentPosition.Value).Length() > 2;
-                            }, $"Wait(tp away from {lastPosition.ToString("G", CultureInfo.InvariantCulture)})");
-                    }
-
-                    return
-                    [
-                        waitInteraction,
-                        serviceProvider.GetRequiredService<WaitDelay>(),
-                        Next(quest, sequence)
-                    ];
-
-                case EInteractionType.AcceptQuest:
-                    return
-                    [
-                        serviceProvider.GetRequiredService<WaitQuestAccepted>().With(step.PickupQuestId ?? quest.QuestId),
-                        serviceProvider.GetRequiredService<WaitDelay>()
-                    ];
-
-                case EInteractionType.CompleteQuest:
-                    return
-                    [
-                        serviceProvider.GetRequiredService<WaitQuestCompleted>().With(step.TurnInQuestId ?? quest.QuestId),
-                        serviceProvider.GetRequiredService<WaitDelay>()
-                    ];
-
-                case EInteractionType.Interact:
-                default:
-                    return [serviceProvider.GetRequiredService<WaitDelay>(), Next(quest, sequence)];
-            }
-        }
-
-        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-            => throw new InvalidOperationException();
-
-        private static NextStep Next(Quest quest, QuestSequence sequence)
-        {
-            return new NextStep(quest.QuestId, sequence.Sequence);
-        }
-    }
-
-    internal sealed class WaitDelay() : AbstractDelayedTask(TimeSpan.FromSeconds(1))
-    {
-        protected override bool StartInternal() => true;
-
-        public override string ToString() => $"Wait(seconds: {Delay.TotalSeconds})";
-    }
-
-    internal sealed class WaitNextStepOrSequence : ITask
-    {
-        public bool Start() => true;
-
-        public ETaskResult Update() => ETaskResult.StillRunning;
-
-        public override string ToString() => "Wait(next step or sequence)";
-    }
-
-    internal sealed class WaitForCompletionFlags(GameFunctions gameFunctions) : ITask
-    {
-        public Quest Quest { get; set; } = null!;
-        public QuestStep Step { get; set; } = null!;
-        public IList<short?> Flags { get; set; } = null!;
-
-        public ITask With(Quest quest, QuestStep step)
-        {
-            Quest = quest;
-            Step = step;
-            Flags = step.CompletionQuestVariablesFlags;
-            return this;
-        }
-
-        public bool Start() => true;
-
-        public ETaskResult Update()
-        {
-            QuestWork? questWork = gameFunctions.GetQuestEx(Quest.QuestId);
-            return questWork != null && Step.MatchesQuestVariables(questWork.Value, false)
-                ? ETaskResult.TaskComplete
-                : ETaskResult.StillRunning;
-        }
-
-        public override string ToString() =>
-            $"Wait(QW: {string.Join(", ", Flags.Select(x => x?.ToString(CultureInfo.InvariantCulture) ?? "-"))})";
-    }
-
-    internal sealed class WaitObjectAtPosition(GameFunctions gameFunctions) : ITask
-    {
-        public uint DataId { get; set; }
-        public Vector3 Destination { get; set; }
-        public float Distance { get; set; }
-
-        public ITask With(uint dataId, Vector3 destination, float distance)
-        {
-            DataId = dataId;
-            Destination = destination;
-            Distance = distance;
-            return this;
-        }
-
-        public bool Start() => true;
-
-        public ETaskResult Update() =>
-            gameFunctions.IsObjectAtPosition(DataId, Destination, Distance)
-                ? ETaskResult.TaskComplete
-                : ETaskResult.StillRunning;
-
-        public override string ToString() =>
-            $"WaitObj({DataId} at {Destination.ToString("G", CultureInfo.InvariantCulture)})";
-    }
-
-    internal sealed class WaitQuestAccepted : ITask
-    {
-        public ushort QuestId { get; set; }
-
-        public ITask With(ushort questId)
-        {
-            QuestId = questId;
-            return this;
-        }
-
-        public bool Start() => true;
-
-        public ETaskResult Update()
-        {
-            unsafe
-            {
-                var questManager = QuestManager.Instance();
-                return questManager != null && questManager->IsQuestAccepted(QuestId)
-                    ? ETaskResult.TaskComplete
-                    : ETaskResult.StillRunning;
-            }
-        }
-
-        public override string ToString() => $"WaitQuestAccepted({QuestId})";
-    }
-
-    internal sealed class WaitQuestCompleted : ITask
-    {
-        public ushort QuestId { get; set; }
-
-        public ITask With(ushort questId)
-        {
-            QuestId = questId;
-            return this;
-        }
-
-        public bool Start() => true;
-
-        public ETaskResult Update()
-        {
-            return QuestManager.IsQuestComplete(QuestId) ? ETaskResult.TaskComplete : ETaskResult.StillRunning;
-        }
-
-        public override string ToString() => $"WaitQuestComplete({QuestId})";
-    }
-
-    internal sealed class NextStep(ushort questId, int sequence) : ILastTask
-    {
-        public ushort QuestId { get; } = questId;
-        public int Sequence { get; } = sequence;
-
-        public bool Start() => true;
-
-        public ETaskResult Update() => ETaskResult.NextStep;
-
-        public override string ToString() => "NextStep";
-    }
-
-    internal sealed class EndAutomation : ILastTask
-    {
-        public ushort QuestId => throw new InvalidOperationException();
-        public int Sequence => throw new InvalidOperationException();
-
-        public bool Start() => true;
-
-        public ETaskResult Update() => ETaskResult.End;
-
-        public override string ToString() => "EndAutomation";
-    }
-}
diff --git a/Questionable/Controller/Steps/BaseFactory/WaitAtStart.cs b/Questionable/Controller/Steps/BaseFactory/WaitAtStart.cs
deleted file mode 100644 (file)
index 5da57f6..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-using System;
-using Microsoft.Extensions.DependencyInjection;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.BaseFactory;
-
-internal static class WaitAtStart
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.DelaySecondsAtStart == null)
-                return null;
-
-            return serviceProvider.GetRequiredService<WaitDelay>()
-                .With(TimeSpan.FromSeconds(step.DelaySecondsAtStart.Value));
-        }
-    }
-
-    internal sealed class WaitDelay : AbstractDelayedTask
-    {
-        public ITask With(TimeSpan delay)
-        {
-            Delay = delay;
-            return this;
-        }
-
-        protected override bool StartInternal() => true;
-
-        public override string ToString() => $"Wait[S](seconds: {Delay.TotalSeconds})";
-    }
-}
diff --git a/Questionable/Controller/Steps/BaseTasks/AbstractDelayedTask.cs b/Questionable/Controller/Steps/BaseTasks/AbstractDelayedTask.cs
deleted file mode 100644 (file)
index ed1e850..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-using System;
-
-namespace Questionable.Controller.Steps.BaseTasks;
-
-internal abstract class AbstractDelayedTask : ITask
-{
-    private DateTime _continueAt;
-
-    protected AbstractDelayedTask(TimeSpan delay)
-    {
-        Delay = delay;
-    }
-
-    protected TimeSpan Delay { get; set; }
-
-    protected AbstractDelayedTask()
-        : this(TimeSpan.FromSeconds(5))
-    {
-    }
-
-    public bool Start()
-    {
-        _continueAt = DateTime.Now.Add(Delay);
-        return StartInternal();
-    }
-
-    protected abstract bool StartInternal();
-
-    public ETaskResult Update()
-    {
-        if (_continueAt >= DateTime.Now)
-            return ETaskResult.StillRunning;
-
-        return UpdateInternal();
-    }
-
-    protected virtual ETaskResult UpdateInternal() => ETaskResult.TaskComplete;
-}
diff --git a/Questionable/Controller/Steps/BaseTasks/MountTask.cs b/Questionable/Controller/Steps/BaseTasks/MountTask.cs
deleted file mode 100644 (file)
index e600aac..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-using System;
-using System.Numerics;
-using Dalamud.Game.ClientState.Conditions;
-using Dalamud.Plugin.Services;
-using Microsoft.Extensions.Logging;
-using Questionable.Data;
-
-namespace Questionable.Controller.Steps.BaseTasks;
-
-internal sealed class MountTask(
-    GameFunctions gameFunctions,
-    ICondition condition,
-    TerritoryData territoryData,
-    IClientState clientState,
-    ILogger<MountTask> logger) : ITask
-{
-    private ushort _territoryId;
-    private EMountIf _mountIf;
-    private Vector3? _position;
-
-    private bool _mountTriggered;
-    private DateTime _retryAt = DateTime.MinValue;
-
-    public ITask With(ushort territoryId, EMountIf mountIf, Vector3? position = null)
-    {
-        _territoryId = territoryId;
-        _mountIf = mountIf;
-        _position = position;
-
-        if (_mountIf == EMountIf.AwayFromPosition)
-            ArgumentNullException.ThrowIfNull(position);
-        return this;
-    }
-
-    public bool Start()
-    {
-        if (condition[ConditionFlag.Mounted])
-            return false;
-
-        if (!territoryData.CanUseMount(_territoryId))
-        {
-            logger.LogInformation("Can't use mount in current territory {Id}", _territoryId);
-            return false;
-        }
-
-        if (gameFunctions.HasStatusPreventingMount())
-        {
-            logger.LogInformation("Can't mount due to status preventing sprint or mount");
-            return false;
-        }
-
-        if (_mountIf == EMountIf.AwayFromPosition)
-        {
-            Vector3 playerPosition = clientState.LocalPlayer?.Position ?? Vector3.Zero;
-            float distance = (playerPosition - _position.GetValueOrDefault()).Length();
-            if (_territoryId == clientState.TerritoryType && distance < 30f)
-            {
-                logger.LogInformation("Not using mount, as we're close to the target");
-                return false;
-            }
-
-            logger.LogInformation(
-                "Want to use mount if away from destination ({Distance} yalms), trying (in territory {Id})...",
-                distance, _territoryId);
-        }
-        else
-            logger.LogInformation("Want to use mount, trying (in territory {Id})...", _territoryId);
-
-        if (!condition[ConditionFlag.InCombat])
-        {
-            _retryAt = DateTime.Now.AddSeconds(0.5);
-            return true;
-        }
-
-        return false;
-    }
-
-    public ETaskResult Update()
-    {
-        if (_mountTriggered && !condition[ConditionFlag.Mounted] && DateTime.Now > _retryAt)
-        {
-            logger.LogInformation("Not mounted, retrying...");
-            _mountTriggered = false;
-            _retryAt = DateTime.MaxValue;
-        }
-
-        if (!_mountTriggered)
-        {
-            if (gameFunctions.HasStatusPreventingMount())
-            {
-                logger.LogInformation("Can't mount due to status preventing sprint or mount");
-                return ETaskResult.TaskComplete;
-            }
-
-            _mountTriggered = gameFunctions.Mount();
-            _retryAt = DateTime.Now.AddSeconds(5);
-            return ETaskResult.StillRunning;
-        }
-
-        return condition[ConditionFlag.Mounted]
-            ? ETaskResult.TaskComplete
-            : ETaskResult.StillRunning;
-    }
-
-    public override string ToString() => "Mount";
-
-    public enum EMountIf
-    {
-        Always,
-        AwayFromPosition,
-    }
-}
diff --git a/Questionable/Controller/Steps/BaseTasks/NextQuest.cs b/Questionable/Controller/Steps/BaseTasks/NextQuest.cs
deleted file mode 100644 (file)
index c03f5a0..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-using System;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.BaseTasks;
-
-internal static class NextQuest
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.CompleteQuest)
-                return null;
-
-            if (step.NextQuestId == null)
-                return null;
-
-            return serviceProvider.GetRequiredService<SetQuest>()
-                .With(step.NextQuestId.Value);
-        }
-    }
-
-    internal sealed class SetQuest(QuestRegistry questRegistry, QuestController questController, ILogger<SetQuest> logger) : ITask
-    {
-        public ushort NextQuestId { get; set; }
-
-        public ITask With(ushort nextQuestId)
-        {
-            NextQuestId = nextQuestId;
-            return this;
-        }
-
-        public bool Start()
-        {
-            if (questRegistry.TryGetQuest(NextQuestId, out Quest? quest))
-            {
-                logger.LogInformation("Setting next quest to {QuestId}: '{QuestName}'", NextQuestId, quest.Info.Name);
-                questController.SetNextQuest(quest);
-            }
-            else
-            {
-                logger.LogInformation("Next quest with id {QuestId} not found", NextQuestId);
-                questController.SetNextQuest(null);
-            }
-
-            return true;
-        }
-
-        public ETaskResult Update() => ETaskResult.TaskComplete;
-
-        public override string ToString() => $"SetNextQuest({NextQuestId})";
-    }
-}
diff --git a/Questionable/Controller/Steps/BaseTasks/UnmountTask.cs b/Questionable/Controller/Steps/BaseTasks/UnmountTask.cs
deleted file mode 100644 (file)
index 54be937..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-using System;
-using Dalamud.Game.ClientState.Conditions;
-using Dalamud.Plugin.Services;
-using Microsoft.Extensions.Logging;
-
-namespace Questionable.Controller.Steps.BaseTasks;
-
-internal sealed class UnmountTask(ICondition condition, ILogger<UnmountTask> logger, GameFunctions gameFunctions)
-    : ITask
-{
-    private bool _unmountTriggered;
-    private DateTime _continueAt = DateTime.MinValue;
-
-    public bool Start()
-    {
-        if (!condition[ConditionFlag.Mounted])
-            return false;
-
-        logger.LogInformation("Step explicitly wants no mount, trying to unmount...");
-        if (condition[ConditionFlag.InFlight])
-        {
-            gameFunctions.Unmount();
-            _continueAt = DateTime.Now.AddSeconds(1);
-            return true;
-        }
-
-        _unmountTriggered = gameFunctions.Unmount();
-        _continueAt = DateTime.Now.AddSeconds(1);
-        return true;
-    }
-
-    public ETaskResult Update()
-    {
-        if (_continueAt >= DateTime.Now)
-            return ETaskResult.StillRunning;
-
-        if (!_unmountTriggered)
-        {
-            // if still flying, we still need to land
-            if (condition[ConditionFlag.InFlight])
-                gameFunctions.Unmount();
-            else
-                _unmountTriggered = gameFunctions.Unmount();
-
-            _continueAt = DateTime.Now.AddSeconds(1);
-            return ETaskResult.StillRunning;
-        }
-
-        return condition[ConditionFlag.Mounted]
-            ? ETaskResult.StillRunning
-            : ETaskResult.TaskComplete;
-    }
-
-    public override string ToString() => "Unmount";
-}
diff --git a/Questionable/Controller/Steps/BaseTasks/WaitConditionTask.cs b/Questionable/Controller/Steps/BaseTasks/WaitConditionTask.cs
deleted file mode 100644 (file)
index 1756546..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-using System;
-
-namespace Questionable.Controller.Steps.BaseTasks;
-
-internal sealed class WaitConditionTask(Func<bool> predicate, string description) : ITask
-{
-    private DateTime _continueAt = DateTime.MaxValue;
-
-    public bool Start() => !predicate();
-
-    public ETaskResult Update()
-    {
-        if (_continueAt == DateTime.MaxValue)
-        {
-            if (predicate())
-                _continueAt = DateTime.Now.AddSeconds(0.5);
-        }
-
-        return DateTime.Now >= _continueAt ? ETaskResult.TaskComplete : ETaskResult.StillRunning;
-    }
-
-    public override string ToString() => description;
-}
diff --git a/Questionable/Controller/Steps/Common/AbstractDelayedTask.cs b/Questionable/Controller/Steps/Common/AbstractDelayedTask.cs
new file mode 100644 (file)
index 0000000..e5a3b10
--- /dev/null
@@ -0,0 +1,38 @@
+using System;
+
+namespace Questionable.Controller.Steps.Common;
+
+internal abstract class AbstractDelayedTask : ITask
+{
+    private DateTime _continueAt;
+
+    protected AbstractDelayedTask(TimeSpan delay)
+    {
+        Delay = delay;
+    }
+
+    protected TimeSpan Delay { get; set; }
+
+    protected AbstractDelayedTask()
+        : this(TimeSpan.FromSeconds(5))
+    {
+    }
+
+    public bool Start()
+    {
+        _continueAt = DateTime.Now.Add(Delay);
+        return StartInternal();
+    }
+
+    protected abstract bool StartInternal();
+
+    public ETaskResult Update()
+    {
+        if (_continueAt >= DateTime.Now)
+            return ETaskResult.StillRunning;
+
+        return UpdateInternal();
+    }
+
+    protected virtual ETaskResult UpdateInternal() => ETaskResult.TaskComplete;
+}
diff --git a/Questionable/Controller/Steps/Common/MountTask.cs b/Questionable/Controller/Steps/Common/MountTask.cs
new file mode 100644 (file)
index 0000000..15585a2
--- /dev/null
@@ -0,0 +1,112 @@
+using System;
+using System.Numerics;
+using Dalamud.Game.ClientState.Conditions;
+using Dalamud.Plugin.Services;
+using Microsoft.Extensions.Logging;
+using Questionable.Data;
+
+namespace Questionable.Controller.Steps.Common;
+
+internal sealed class MountTask(
+    GameFunctions gameFunctions,
+    ICondition condition,
+    TerritoryData territoryData,
+    IClientState clientState,
+    ILogger<MountTask> logger) : ITask
+{
+    private ushort _territoryId;
+    private EMountIf _mountIf;
+    private Vector3? _position;
+
+    private bool _mountTriggered;
+    private DateTime _retryAt = DateTime.MinValue;
+
+    public ITask With(ushort territoryId, EMountIf mountIf, Vector3? position = null)
+    {
+        _territoryId = territoryId;
+        _mountIf = mountIf;
+        _position = position;
+
+        if (_mountIf == EMountIf.AwayFromPosition)
+            ArgumentNullException.ThrowIfNull(position);
+        return this;
+    }
+
+    public bool Start()
+    {
+        if (condition[ConditionFlag.Mounted])
+            return false;
+
+        if (!territoryData.CanUseMount(_territoryId))
+        {
+            logger.LogInformation("Can't use mount in current territory {Id}", _territoryId);
+            return false;
+        }
+
+        if (gameFunctions.HasStatusPreventingMount())
+        {
+            logger.LogInformation("Can't mount due to status preventing sprint or mount");
+            return false;
+        }
+
+        if (_mountIf == EMountIf.AwayFromPosition)
+        {
+            Vector3 playerPosition = clientState.LocalPlayer?.Position ?? Vector3.Zero;
+            float distance = (playerPosition - _position.GetValueOrDefault()).Length();
+            if (_territoryId == clientState.TerritoryType && distance < 30f)
+            {
+                logger.LogInformation("Not using mount, as we're close to the target");
+                return false;
+            }
+
+            logger.LogInformation(
+                "Want to use mount if away from destination ({Distance} yalms), trying (in territory {Id})...",
+                distance, _territoryId);
+        }
+        else
+            logger.LogInformation("Want to use mount, trying (in territory {Id})...", _territoryId);
+
+        if (!condition[ConditionFlag.InCombat])
+        {
+            _retryAt = DateTime.Now.AddSeconds(0.5);
+            return true;
+        }
+
+        return false;
+    }
+
+    public ETaskResult Update()
+    {
+        if (_mountTriggered && !condition[ConditionFlag.Mounted] && DateTime.Now > _retryAt)
+        {
+            logger.LogInformation("Not mounted, retrying...");
+            _mountTriggered = false;
+            _retryAt = DateTime.MaxValue;
+        }
+
+        if (!_mountTriggered)
+        {
+            if (gameFunctions.HasStatusPreventingMount())
+            {
+                logger.LogInformation("Can't mount due to status preventing sprint or mount");
+                return ETaskResult.TaskComplete;
+            }
+
+            _mountTriggered = gameFunctions.Mount();
+            _retryAt = DateTime.Now.AddSeconds(5);
+            return ETaskResult.StillRunning;
+        }
+
+        return condition[ConditionFlag.Mounted]
+            ? ETaskResult.TaskComplete
+            : ETaskResult.StillRunning;
+    }
+
+    public override string ToString() => "Mount";
+
+    public enum EMountIf
+    {
+        Always,
+        AwayFromPosition,
+    }
+}
diff --git a/Questionable/Controller/Steps/Common/NextQuest.cs b/Questionable/Controller/Steps/Common/NextQuest.cs
new file mode 100644 (file)
index 0000000..32d0f11
--- /dev/null
@@ -0,0 +1,56 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Common;
+
+internal static class NextQuest
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.CompleteQuest)
+                return null;
+
+            if (step.NextQuestId == null)
+                return null;
+
+            return serviceProvider.GetRequiredService<SetQuest>()
+                .With(step.NextQuestId.Value);
+        }
+    }
+
+    internal sealed class SetQuest(QuestRegistry questRegistry, QuestController questController, ILogger<SetQuest> logger) : ITask
+    {
+        public ushort NextQuestId { get; set; }
+
+        public ITask With(ushort nextQuestId)
+        {
+            NextQuestId = nextQuestId;
+            return this;
+        }
+
+        public bool Start()
+        {
+            if (questRegistry.TryGetQuest(NextQuestId, out Quest? quest))
+            {
+                logger.LogInformation("Setting next quest to {QuestId}: '{QuestName}'", NextQuestId, quest.Info.Name);
+                questController.SetNextQuest(quest);
+            }
+            else
+            {
+                logger.LogInformation("Next quest with id {QuestId} not found", NextQuestId);
+                questController.SetNextQuest(null);
+            }
+
+            return true;
+        }
+
+        public ETaskResult Update() => ETaskResult.TaskComplete;
+
+        public override string ToString() => $"SetNextQuest({NextQuestId})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Common/UnmountTask.cs b/Questionable/Controller/Steps/Common/UnmountTask.cs
new file mode 100644 (file)
index 0000000..ad36958
--- /dev/null
@@ -0,0 +1,55 @@
+using System;
+using Dalamud.Game.ClientState.Conditions;
+using Dalamud.Plugin.Services;
+using Microsoft.Extensions.Logging;
+
+namespace Questionable.Controller.Steps.Common;
+
+internal sealed class UnmountTask(ICondition condition, ILogger<UnmountTask> logger, GameFunctions gameFunctions)
+    : ITask
+{
+    private bool _unmountTriggered;
+    private DateTime _continueAt = DateTime.MinValue;
+
+    public bool Start()
+    {
+        if (!condition[ConditionFlag.Mounted])
+            return false;
+
+        logger.LogInformation("Step explicitly wants no mount, trying to unmount...");
+        if (condition[ConditionFlag.InFlight])
+        {
+            gameFunctions.Unmount();
+            _continueAt = DateTime.Now.AddSeconds(1);
+            return true;
+        }
+
+        _unmountTriggered = gameFunctions.Unmount();
+        _continueAt = DateTime.Now.AddSeconds(1);
+        return true;
+    }
+
+    public ETaskResult Update()
+    {
+        if (_continueAt >= DateTime.Now)
+            return ETaskResult.StillRunning;
+
+        if (!_unmountTriggered)
+        {
+            // if still flying, we still need to land
+            if (condition[ConditionFlag.InFlight])
+                gameFunctions.Unmount();
+            else
+                _unmountTriggered = gameFunctions.Unmount();
+
+            _continueAt = DateTime.Now.AddSeconds(1);
+            return ETaskResult.StillRunning;
+        }
+
+        return condition[ConditionFlag.Mounted]
+            ? ETaskResult.StillRunning
+            : ETaskResult.TaskComplete;
+    }
+
+    public override string ToString() => "Unmount";
+}
diff --git a/Questionable/Controller/Steps/Common/WaitConditionTask.cs b/Questionable/Controller/Steps/Common/WaitConditionTask.cs
new file mode 100644 (file)
index 0000000..762904f
--- /dev/null
@@ -0,0 +1,23 @@
+using System;
+
+namespace Questionable.Controller.Steps.Common;
+
+internal sealed class WaitConditionTask(Func<bool> predicate, string description) : ITask
+{
+    private DateTime _continueAt = DateTime.MaxValue;
+
+    public bool Start() => !predicate();
+
+    public ETaskResult Update()
+    {
+        if (_continueAt == DateTime.MaxValue)
+        {
+            if (predicate())
+                _continueAt = DateTime.Now.AddSeconds(0.5);
+        }
+
+        return DateTime.Now >= _continueAt ? ETaskResult.TaskComplete : ETaskResult.StillRunning;
+    }
+
+    public override string ToString() => description;
+}
diff --git a/Questionable/Controller/Steps/InteractionFactory/Action.cs b/Questionable/Controller/Steps/InteractionFactory/Action.cs
deleted file mode 100644 (file)
index a94f706..0000000
+++ /dev/null
@@ -1,91 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Dalamud.Game.ClientState.Conditions;
-using Dalamud.Game.ClientState.Objects.Types;
-using FFXIVClientStructs.FFXIV.Client.Game;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class Action
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.Action)
-                return [];
-
-            ArgumentNullException.ThrowIfNull(step.DataId);
-            ArgumentNullException.ThrowIfNull(step.Action);
-
-            var unmount = serviceProvider.GetRequiredService<UnmountTask>();
-            var task = serviceProvider.GetRequiredService<UseOnObject>()
-                .With(step.DataId.Value, step.Action.Value);
-            return [unmount, task];
-        }
-
-        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-            => throw new InvalidOperationException();
-    }
-
-    internal sealed class UseOnObject(GameFunctions gameFunctions, ILogger<UseOnObject> logger) : ITask
-    {
-        private bool _usedAction;
-        private DateTime _continueAt = DateTime.MinValue;
-
-        public uint DataId { get; set; }
-        public EAction Action { get; set; }
-
-        public ITask With(uint dataId, EAction action)
-        {
-            DataId = dataId;
-            Action = action;
-            return this;
-        }
-
-        public bool Start()
-        {
-            IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
-            if (gameObject == null)
-            {
-                logger.LogWarning("No game object with dataId {DataId}", DataId);
-                return false;
-            }
-
-            if (gameObject.IsTargetable)
-            {
-                _usedAction = gameFunctions.UseAction(gameObject, Action);
-                _continueAt = DateTime.Now.AddSeconds(0.5);
-                return true;
-            }
-
-            return true;
-        }
-
-        public ETaskResult Update()
-        {
-            if (DateTime.Now <= _continueAt)
-                return ETaskResult.StillRunning;
-
-            if (!_usedAction)
-            {
-                IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
-                if (gameObject == null || !gameObject.IsTargetable)
-                    return ETaskResult.StillRunning;
-
-                _usedAction = gameFunctions.UseAction(gameObject, Action);
-                _continueAt = DateTime.Now.AddSeconds(0.5);
-                return ETaskResult.StillRunning;
-            }
-
-            return ETaskResult.TaskComplete;
-        }
-
-        public override string ToString() => $"Action({Action})";
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/AetherCurrent.cs b/Questionable/Controller/Steps/InteractionFactory/AetherCurrent.cs
deleted file mode 100644 (file)
index 6be7eb8..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-using System;
-using Dalamud.Plugin.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Data;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class AetherCurrent
-{
-    internal sealed class Factory(IServiceProvider serviceProvider, AetherCurrentData aetherCurrentData, IChatGui chatGui) : ITaskFactory
-    {
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.AttuneAetherCurrent)
-                return null;
-
-            ArgumentNullException.ThrowIfNull(step.DataId);
-            ArgumentNullException.ThrowIfNull(step.AetherCurrentId);
-
-            if (!aetherCurrentData.IsValidAetherCurrent(step.TerritoryId, step.AetherCurrentId.Value))
-            {
-                chatGui.PrintError($"[Questionable] Aether current with id {step.AetherCurrentId} is referencing an invalid aether current, will skip attunement");
-                return null;
-            }
-
-            return serviceProvider.GetRequiredService<DoAttune>()
-                .With(step.DataId.Value, step.AetherCurrentId.Value);
-        }
-    }
-
-    internal sealed class DoAttune(GameFunctions gameFunctions, ILogger<DoAttune> logger) : ITask
-    {
-        public uint DataId { get; set; }
-        public uint AetherCurrentId { get; set; }
-
-        public ITask With(uint dataId, uint aetherCurrentId)
-        {
-            DataId = dataId;
-            AetherCurrentId = aetherCurrentId;
-            return this;
-        }
-
-        public bool Start()
-        {
-            if (!gameFunctions.IsAetherCurrentUnlocked(AetherCurrentId))
-            {
-                logger.LogInformation("Attuning to aether current {AetherCurrentId} / {DataId}", AetherCurrentId,
-                    DataId);
-                gameFunctions.InteractWith(DataId);
-                return true;
-            }
-
-            logger.LogInformation("Already attuned to aether current {AetherCurrentId} / {DataId}", AetherCurrentId,
-                DataId);
-            return false;
-        }
-
-        public ETaskResult Update() =>
-            gameFunctions.IsAetherCurrentUnlocked(AetherCurrentId)
-                ? ETaskResult.TaskComplete
-                : ETaskResult.StillRunning;
-
-        public override string ToString() => $"AttuneAetherCurrent({AetherCurrentId})";
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/AethernetShard.cs b/Questionable/Controller/Steps/InteractionFactory/AethernetShard.cs
deleted file mode 100644 (file)
index 1897615..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-using System;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class AethernetShard
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.AttuneAethernetShard)
-                return null;
-
-            ArgumentNullException.ThrowIfNull(step.DataId);
-
-            return serviceProvider.GetRequiredService<DoAttune>()
-                .With((EAetheryteLocation)step.DataId);
-        }
-    }
-
-    internal sealed class DoAttune(GameFunctions gameFunctions, ILogger<DoAttune> logger) : ITask
-    {
-        public EAetheryteLocation AetheryteLocation { get; set; }
-
-        public ITask? With(EAetheryteLocation aetheryteLocation)
-        {
-            AetheryteLocation = aetheryteLocation;
-            return this;
-        }
-
-        public bool Start()
-        {
-            if (!gameFunctions.IsAetheryteUnlocked(AetheryteLocation))
-            {
-                logger.LogInformation("Attuning to aethernet shard {AethernetShard}", AetheryteLocation);
-                gameFunctions.InteractWith((uint)AetheryteLocation);
-                return true;
-            }
-
-            logger.LogInformation("Already attuned to aethernet shard {AethernetShard}", AetheryteLocation);
-            return false;
-        }
-
-        public ETaskResult Update() =>
-            gameFunctions.IsAetheryteUnlocked(AetheryteLocation)
-                ? ETaskResult.TaskComplete
-                : ETaskResult.StillRunning;
-
-        public override string ToString() => $"AttuneAethernetShard({AetheryteLocation})";
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/Aetheryte.cs b/Questionable/Controller/Steps/InteractionFactory/Aetheryte.cs
deleted file mode 100644 (file)
index 8f59a3d..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-using System;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class Aetheryte
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.AttuneAetheryte)
-                return null;
-
-            ArgumentNullException.ThrowIfNull(step.DataId);
-
-            return serviceProvider.GetRequiredService<DoAttune>()
-                .With((EAetheryteLocation)step.DataId.Value);
-        }
-    }
-
-    internal sealed class DoAttune(GameFunctions gameFunctions, ILogger<DoAttune> logger) : ITask
-    {
-        public EAetheryteLocation AetheryteLocation { get; set; }
-
-        public ITask With(EAetheryteLocation aetheryteLocation)
-        {
-            AetheryteLocation = aetheryteLocation;
-            return this;
-        }
-
-        public bool Start()
-        {
-            if (!gameFunctions.IsAetheryteUnlocked(AetheryteLocation))
-            {
-                logger.LogInformation("Attuning to aetheryte {Aetheryte}", AetheryteLocation);
-                gameFunctions.InteractWith((uint)AetheryteLocation);
-                return true;
-            }
-
-            logger.LogInformation("Already attuned to aetheryte {Aetheryte}", AetheryteLocation);
-            return false;
-        }
-
-        public ETaskResult Update() =>
-            gameFunctions.IsAetheryteUnlocked(AetheryteLocation)
-                ? ETaskResult.TaskComplete
-                : ETaskResult.StillRunning;
-
-        public override string ToString() => $"AttuneAetheryte({AetheryteLocation})";
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/Combat.cs b/Questionable/Controller/Steps/InteractionFactory/Combat.cs
deleted file mode 100644 (file)
index b9ebcc7..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Microsoft.Extensions.DependencyInjection;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class Combat
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.Combat)
-                return [];
-
-            ArgumentNullException.ThrowIfNull(step.EnemySpawnType);
-
-            var unmount = serviceProvider.GetRequiredService<UnmountTask>();
-            if (step.EnemySpawnType == EEnemySpawnType.AfterInteraction)
-            {
-                ArgumentNullException.ThrowIfNull(step.DataId);
-
-                var task = serviceProvider.GetRequiredService<Interact.DoInteract>()
-                    .With(step.DataId.Value, true);
-                return [unmount, task];
-            }
-            else if (step.EnemySpawnType == EEnemySpawnType.AfterItemUse)
-            {
-                ArgumentNullException.ThrowIfNull(step.DataId);
-                ArgumentNullException.ThrowIfNull(step.ItemId);
-
-                var task = serviceProvider.GetRequiredService<UseItem.UseOnObject>()
-                    .With(step.DataId.Value, step.ItemId.Value);
-                return [unmount, task];
-            }
-            else
-                // automatically triggered when entering area, i.e. only unmount
-                return [unmount];
-        }
-
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-            => throw new InvalidOperationException();
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/Duty.cs b/Questionable/Controller/Steps/InteractionFactory/Duty.cs
deleted file mode 100644 (file)
index 9a57f4f..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-using System;
-using Dalamud.Game.ClientState.Conditions;
-using Dalamud.Plugin.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class Duty
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.Duty)
-                return null;
-
-            ArgumentNullException.ThrowIfNull(step.ContentFinderConditionId);
-
-            return serviceProvider.GetRequiredService<OpenDutyFinder>()
-                .With(step.ContentFinderConditionId.Value);
-        }
-    }
-
-    internal sealed class OpenDutyFinder(GameFunctions gameFunctions, ICondition condition) : ITask
-    {
-        public uint ContentFinderConditionId { get; set; }
-
-        public ITask With(uint contentFinderConditionId)
-        {
-            ContentFinderConditionId = contentFinderConditionId;
-            return this;
-        }
-
-        public bool Start()
-        {
-            if (condition[ConditionFlag.InDutyQueue])
-                return false;
-
-            gameFunctions.OpenDutyFinder(ContentFinderConditionId);
-            return true;
-        }
-
-        public ETaskResult Update() => ETaskResult.TaskComplete;
-
-        public override string ToString() => $"OpenDutyFinder({ContentFinderConditionId})";
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/Emote.cs b/Questionable/Controller/Steps/InteractionFactory/Emote.cs
deleted file mode 100644 (file)
index a8dee7b..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Microsoft.Extensions.DependencyInjection;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class Emote
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType is EInteractionType.AcceptQuest or EInteractionType.CompleteQuest)
-            {
-                if (step.Emote == null)
-                    return [];
-            }
-            else if (step.InteractionType != EInteractionType.Emote)
-                return [];
-
-            ArgumentNullException.ThrowIfNull(step.Emote);
-
-            var unmount = serviceProvider.GetRequiredService<UnmountTask>();
-            if (step.DataId != null)
-            {
-                var task = serviceProvider.GetRequiredService<UseOnObject>().With(step.Emote.Value, step.DataId.Value);
-                return [unmount, task];
-            }
-            else
-            {
-                var task = serviceProvider.GetRequiredService<Use>().With(step.Emote.Value);
-                return [unmount, task];
-            }
-        }
-
-        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-            => throw new InvalidOperationException();
-    }
-
-    internal sealed class UseOnObject(ChatFunctions chatFunctions) : AbstractDelayedTask
-    {
-        public EEmote Emote { get; set; }
-        public uint DataId { get; set; }
-
-        public ITask With(EEmote emote, uint dataId)
-        {
-            Emote = emote;
-            DataId = dataId;
-            return this;
-        }
-
-        protected override bool StartInternal()
-        {
-            chatFunctions.UseEmote(DataId, Emote);
-            return true;
-        }
-
-        public override string ToString() => $"Emote({Emote} on {DataId})";
-    }
-
-    internal sealed class Use(ChatFunctions chatFunctions) : AbstractDelayedTask
-    {
-        public EEmote Emote { get; set; }
-
-        public ITask With(EEmote emote)
-        {
-            Emote = emote;
-            return this;
-        }
-
-        protected override bool StartInternal()
-        {
-            chatFunctions.UseEmote(Emote);
-            return true;
-        }
-
-        public override string ToString() => $"Emote({Emote})";
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/EquipItem.cs b/Questionable/Controller/Steps/InteractionFactory/EquipItem.cs
deleted file mode 100644 (file)
index 22e339e..0000000
+++ /dev/null
@@ -1,157 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Dalamud.Plugin.Services;
-using FFXIVClientStructs.FFXIV.Client.Game;
-using Lumina.Excel.GeneratedSheets;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Model.V1;
-using Quest = Questionable.Model.Quest;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class EquipItem
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.EquipItem)
-                return null;
-
-            ArgumentNullException.ThrowIfNull(step.ItemId);
-            return serviceProvider.GetRequiredService<DoEquip>()
-                .With(step.ItemId.Value);
-        }
-    }
-
-    internal sealed class DoEquip(IDataManager dataManager, ILogger<DoEquip> logger) : ITask
-    {
-        private static readonly IReadOnlyList<InventoryType> SourceInventoryTypes =
-        [
-            InventoryType.ArmoryMainHand,
-            InventoryType.ArmoryOffHand,
-            InventoryType.ArmoryHead,
-            InventoryType.ArmoryBody,
-            InventoryType.ArmoryHands,
-            InventoryType.ArmoryLegs,
-            InventoryType.ArmoryFeets,
-
-            InventoryType.ArmoryEar,
-            InventoryType.ArmoryNeck,
-            InventoryType.ArmoryWrist,
-            InventoryType.ArmoryRings,
-
-            InventoryType.Inventory1,
-            InventoryType.Inventory2,
-            InventoryType.Inventory3,
-            InventoryType.Inventory4,
-        ];
-
-        private uint _itemId;
-        private Item _item = null!;
-        private List<ushort> _targetSlots = [];
-
-        private DateTime _continueAt = DateTime.MaxValue;
-
-        public ITask With(uint itemId)
-        {
-            _itemId = itemId;
-            _item = dataManager.GetExcelSheet<Item>()!.GetRow(itemId) ??
-                    throw new ArgumentOutOfRangeException(nameof(itemId));
-            _targetSlots = GetEquipSlot(_item) ?? throw new InvalidOperationException("Not a piece of equipment");
-            return this;
-        }
-
-        public bool Start()
-        {
-            Equip();
-            _continueAt = DateTime.Now.AddSeconds(1);
-            return true;
-        }
-
-        public unsafe ETaskResult Update()
-        {
-            if (DateTime.Now < _continueAt)
-                return ETaskResult.StillRunning;
-
-            InventoryManager* inventoryManager = InventoryManager.Instance();
-            if (inventoryManager == null)
-                return ETaskResult.StillRunning;
-
-            if (_targetSlots.Any(x =>
-                    inventoryManager->GetInventorySlot(InventoryType.EquippedItems, x)->ItemId == _itemId))
-                return ETaskResult.TaskComplete;
-
-            Equip();
-            _continueAt = DateTime.Now.AddSeconds(1);
-            return ETaskResult.StillRunning;
-        }
-
-        private unsafe bool Equip()
-        {
-            var inventoryManager = InventoryManager.Instance();
-            if (inventoryManager == null)
-                return false;
-
-            var equippedContainer = inventoryManager->GetInventoryContainer(InventoryType.EquippedItems);
-            if (equippedContainer == null)
-                return false;
-
-            if (_targetSlots.Any(slot => equippedContainer->GetInventorySlot(slot)->ItemId == _itemId))
-            {
-                logger.LogInformation("Already equipped {Item}, skipping step", _item.Name?.ToString());
-                return false;
-            }
-
-            foreach (InventoryType sourceInventoryType in SourceInventoryTypes)
-            {
-                var sourceContainer = inventoryManager->GetInventoryContainer(sourceInventoryType);
-                if (sourceContainer == null)
-                    continue;
-
-                if (inventoryManager->GetItemCountInContainer(_itemId, sourceInventoryType, true) == 0 &&
-                    inventoryManager->GetItemCountInContainer(_itemId, sourceInventoryType) == 0)
-                    continue;
-
-                for (ushort sourceSlot = 0; sourceSlot < sourceContainer->Size; sourceSlot++)
-                {
-                    var sourceItem = sourceContainer->GetInventorySlot(sourceSlot);
-                    if (sourceItem == null || sourceItem->ItemId != _itemId)
-                        continue;
-
-                    // Move the item to the first available slot
-                    ushort targetSlot = _targetSlots
-                        .Where(x => inventoryManager->GetInventorySlot(InventoryType.EquippedItems, x)->ItemId == 0)
-                        .Concat(_targetSlots).First();
-
-                    logger.LogInformation(
-                        "Equipping item from {SourceInventory}, {SourceSlot} to {TargetInventory}, {TargetSlot}",
-                        sourceInventoryType, sourceSlot, InventoryType.EquippedItems, targetSlot);
-
-                    int result = inventoryManager->MoveItemSlot(sourceInventoryType, sourceSlot,
-                        InventoryType.EquippedItems, targetSlot, 1);
-                    logger.LogInformation("MoveItemSlot result: {Result}", result);
-                    return result == 0;
-                }
-            }
-
-            return false;
-        }
-
-        private static List<ushort>? GetEquipSlot(Item item)
-        {
-            return item.EquipSlotCategory.Row switch
-            {
-                >= 1 and <= 11 => [(ushort)(item.EquipSlotCategory.Row - 1)],
-                12 => [11, 12], // rings
-                17 => [14], // soul crystal
-                _ => null
-            };
-        }
-
-        public override string ToString() => $"Equip({_item.Name})";
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/Interact.cs b/Questionable/Controller/Steps/InteractionFactory/Interact.cs
deleted file mode 100644 (file)
index ea55513..0000000
+++ /dev/null
@@ -1,130 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Dalamud.Game.ClientState.Conditions;
-using Dalamud.Game.ClientState.Objects.Enums;
-using Dalamud.Game.ClientState.Objects.Types;
-using Dalamud.Plugin.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Controller.Steps.BaseFactory;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class Interact
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType is EInteractionType.AcceptQuest or EInteractionType.CompleteQuest)
-            {
-                if (step.Emote != null)
-                    yield break;
-            }
-            else if (step.InteractionType != EInteractionType.Interact)
-                yield break;
-
-            ArgumentNullException.ThrowIfNull(step.DataId);
-
-            // if we're fast enough, it is possible to get the smalltalk prompt
-            if (sequence.Sequence == 0 && sequence.Steps.IndexOf(step) == 0)
-                yield return serviceProvider.GetRequiredService<WaitAtEnd.WaitDelay>();
-
-            yield return serviceProvider.GetRequiredService<DoInteract>()
-                .With(step.DataId.Value, step.TargetTerritoryId != null);
-        }
-
-        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-            => throw new InvalidOperationException();
-    }
-
-    internal sealed class DoInteract(GameFunctions gameFunctions, ICondition condition, ILogger<DoInteract> logger)
-        : ITask
-    {
-        private bool _needsUnmount;
-        private bool _interacted;
-        private DateTime _continueAt = DateTime.MinValue;
-
-        private uint DataId { get; set; }
-        private bool SkipMarkerCheck { get; set; }
-
-        public ITask With(uint dataId, bool skipMarkerCheck)
-        {
-            DataId = dataId;
-            SkipMarkerCheck = skipMarkerCheck;
-            return this;
-        }
-
-        public bool Start()
-        {
-            IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
-            if (gameObject == null)
-            {
-                logger.LogWarning("No game object with dataId {DataId}", DataId);
-                return false;
-            }
-
-            // this is only relevant for followers on quests
-            if (!gameObject.IsTargetable && condition[ConditionFlag.Mounted])
-            {
-                _needsUnmount = true;
-                gameFunctions.Unmount();
-                _continueAt = DateTime.Now.AddSeconds(1);
-                return true;
-            }
-
-            if (gameObject.IsTargetable && HasAnyMarker(gameObject))
-            {
-                _interacted = gameFunctions.InteractWith(DataId);
-                _continueAt = DateTime.Now.AddSeconds(0.5);
-                return true;
-            }
-
-            return true;
-        }
-
-        public ETaskResult Update()
-        {
-            if (DateTime.Now <= _continueAt)
-                return ETaskResult.StillRunning;
-
-            if (_needsUnmount)
-            {
-                if (condition[ConditionFlag.Mounted])
-                {
-                    gameFunctions.Unmount();
-                    _continueAt = DateTime.Now.AddSeconds(1);
-                    return ETaskResult.StillRunning;
-                }
-                else
-                    _needsUnmount = false;
-            }
-
-            if (!_interacted)
-            {
-                IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
-                if (gameObject == null || !gameObject.IsTargetable || !HasAnyMarker(gameObject))
-                    return ETaskResult.StillRunning;
-
-                _interacted = gameFunctions.InteractWith(DataId);
-                _continueAt = DateTime.Now.AddSeconds(0.5);
-                return ETaskResult.StillRunning;
-            }
-
-            return ETaskResult.TaskComplete;
-        }
-
-        private unsafe bool HasAnyMarker(IGameObject gameObject)
-        {
-            if (SkipMarkerCheck || gameObject.ObjectKind != ObjectKind.EventNpc)
-                return true;
-
-            var gameObjectStruct = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)gameObject.Address;
-            return gameObjectStruct->NamePlateIconId != 0;
-        }
-
-        public override string ToString() => $"Interact({DataId})";
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/Jump.cs b/Questionable/Controller/Steps/InteractionFactory/Jump.cs
deleted file mode 100644 (file)
index da029ac..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-using System;
-using Dalamud.Plugin.Services;
-using FFXIVClientStructs.FFXIV.Client.Game;
-using Microsoft.Extensions.DependencyInjection;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class Jump
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.Jump)
-                return null;
-
-            ArgumentNullException.ThrowIfNull(step.JumpDestination);
-
-            return serviceProvider.GetRequiredService<DoJump>()
-                .With(step.DataId, step.JumpDestination, step.Comment);
-        }
-    }
-
-    internal sealed class DoJump(
-        MovementController movementController,
-        IClientState clientState,
-        IFramework framework) : ITask
-    {
-        public uint? DataId { get; set; }
-        public JumpDestination JumpDestination { get; set; } = null!;
-        public string? Comment { get; set; }
-
-        public ITask With(uint? dataId, JumpDestination jumpDestination, string? comment)
-        {
-            DataId = dataId;
-            JumpDestination = jumpDestination;
-            Comment = comment ?? string.Empty;
-            return this;
-        }
-
-        public bool Start()
-        {
-            float stopDistance = JumpDestination.StopDistance ?? 1f;
-            if ((clientState.LocalPlayer!.Position - JumpDestination.Position).Length() <= stopDistance)
-                return false;
-
-            movementController.NavigateTo(EMovementType.Quest, DataId, [JumpDestination.Position], false, false,
-                JumpDestination.StopDistance ?? stopDistance);
-            framework.RunOnTick(() =>
-                {
-                    unsafe
-                    {
-                        ActionManager.Instance()->UseAction(ActionType.GeneralAction, 2);
-                    }
-                },
-                TimeSpan.FromSeconds(JumpDestination.DelaySeconds ?? 0.5f));
-            return true;
-        }
-
-        public ETaskResult Update()
-        {
-            if (movementController.IsPathfinding || movementController.IsPathRunning)
-                return ETaskResult.StillRunning;
-
-            DateTime movementStartedAt = movementController.MovementStartedAt;
-            if (movementStartedAt == DateTime.MaxValue || movementStartedAt.AddSeconds(2) >= DateTime.Now)
-                return ETaskResult.StillRunning;
-
-            return ETaskResult.TaskComplete;
-        }
-
-        public override string ToString() => $"Jump({Comment})";
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/Say.cs b/Questionable/Controller/Steps/InteractionFactory/Say.cs
deleted file mode 100644 (file)
index 3772252..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Microsoft.Extensions.DependencyInjection;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class Say
-{
-    internal sealed class Factory(IServiceProvider serviceProvider, GameFunctions gameFunctions) : ITaskFactory
-    {
-        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.Say)
-                return [];
-
-
-            ArgumentNullException.ThrowIfNull(step.ChatMessage);
-
-            string? excelString =
-                gameFunctions.GetDialogueText(quest, step.ChatMessage.ExcelSheet, step.ChatMessage.Key);
-            ArgumentNullException.ThrowIfNull(excelString);
-
-            var unmount = serviceProvider.GetRequiredService<UnmountTask>();
-            var task = serviceProvider.GetRequiredService<UseChat>().With(excelString);
-            return [unmount, task];
-        }
-
-        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-            => throw new InvalidOperationException();
-    }
-
-    internal sealed class UseChat(ChatFunctions chatFunctions) : AbstractDelayedTask
-    {
-        public string ChatMessage { get; set; } = null!;
-
-        public ITask With(string chatMessage)
-        {
-            ChatMessage = chatMessage;
-            return this;
-        }
-
-        protected override bool StartInternal()
-        {
-            chatFunctions.ExecuteCommand($"/say {ChatMessage}");
-            return true;
-        }
-
-        public override string ToString() => $"Say({ChatMessage})";
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/SinglePlayerDuty.cs b/Questionable/Controller/Steps/InteractionFactory/SinglePlayerDuty.cs
deleted file mode 100644 (file)
index d80be1f..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Dalamud.Plugin.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Questionable.External;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class SinglePlayerDuty
-{
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.SinglePlayerDuty)
-                return [];
-
-            ArgumentNullException.ThrowIfNull(step.DataId);
-            return
-            [
-                serviceProvider.GetRequiredService<DisableYesAlready>(),
-                serviceProvider.GetRequiredService<Interact.DoInteract>()
-                    .With(step.DataId.Value, true),
-                serviceProvider.GetRequiredService<RestoreYesAlready>()
-            ];
-        }
-
-        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-            => throw new InvalidOperationException();
-    }
-
-    internal sealed class DisableYesAlready(YesAlreadyIpc yesAlreadyIpc) : ITask
-    {
-        public bool Start()
-        {
-            yesAlreadyIpc.DisableYesAlready();
-            return true;
-        }
-
-        public ETaskResult Update() => ETaskResult.TaskComplete;
-
-        public override string ToString() => "DisableYA";
-    }
-
-    internal sealed class RestoreYesAlready(YesAlreadyIpc yesAlreadyIpc, IGameGui gameGui) : ITask
-    {
-        public bool Start() => true;
-
-        public ETaskResult Update()
-        {
-            if (gameGui.GetAddonByName("SelectYesno") != nint.Zero ||
-                gameGui.GetAddonByName("DifficultySelectYesNo") != nint.Zero)
-                return ETaskResult.StillRunning;
-
-            yesAlreadyIpc.RestoreYesAlready();
-            return ETaskResult.TaskComplete;
-        }
-
-        public override string ToString() => "Wait(DialogClosed) → RestoreYA";
-    }
-}
diff --git a/Questionable/Controller/Steps/InteractionFactory/UseItem.cs b/Questionable/Controller/Steps/InteractionFactory/UseItem.cs
deleted file mode 100644 (file)
index 2f752b1..0000000
+++ /dev/null
@@ -1,161 +0,0 @@
-using System;
-using System.Collections.Generic;
-using FFXIVClientStructs.FFXIV.Client.Game;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Model;
-using Questionable.Model.V1;
-
-namespace Questionable.Controller.Steps.InteractionFactory;
-
-internal static class UseItem
-{
-    public const int VesperBayAetheryteTicket = 30362;
-
-    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
-    {
-        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
-        {
-            if (step.InteractionType != EInteractionType.UseItem)
-                return [];
-
-            ArgumentNullException.ThrowIfNull(step.ItemId);
-
-            var unmount = serviceProvider.GetRequiredService<UnmountTask>();
-            if (step.GroundTarget == true)
-            {
-                ArgumentNullException.ThrowIfNull(step.DataId);
-
-                var task = serviceProvider.GetRequiredService<UseOnGround>()
-                    .With(step.DataId.Value, step.ItemId.Value);
-                return [unmount, task];
-            }
-            else if (step.DataId != null)
-            {
-                var task = serviceProvider.GetRequiredService<UseOnObject>()
-                    .With(step.DataId.Value, step.ItemId.Value);
-                return [unmount, task];
-            }
-            else
-            {
-                var task = serviceProvider.GetRequiredService<Use>()
-                    .With(step.ItemId.Value);
-                return [unmount, task];
-            }
-        }
-
-        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
-            => throw new InvalidOperationException();
-    }
-
-    internal abstract class UseItemBase(ILogger logger) : ITask
-    {
-        private bool _usedItem;
-        private DateTime _continueAt;
-        private int _itemCount;
-
-        public uint ItemId { get; set; }
-
-        protected abstract bool UseItem();
-
-        public unsafe bool Start()
-        {
-            InventoryManager* inventoryManager = InventoryManager.Instance();
-            if (inventoryManager == null)
-                throw new TaskException("No InventoryManager");
-
-            _itemCount = inventoryManager->GetInventoryItemCount(ItemId);
-            if (_itemCount == 0)
-                throw new TaskException($"Don't have any {ItemId} in inventory (NQ only)");
-
-            _usedItem = UseItem();
-            if (ItemId == VesperBayAetheryteTicket)
-                _continueAt = DateTime.Now.AddSeconds(11);
-            else
-                _continueAt = DateTime.Now.AddSeconds(2);
-            return true;
-        }
-
-        public unsafe ETaskResult Update()
-        {
-            if (DateTime.Now <= _continueAt)
-                return ETaskResult.StillRunning;
-
-            if (ItemId == VesperBayAetheryteTicket)
-            {
-                InventoryManager* inventoryManager = InventoryManager.Instance();
-                if (inventoryManager == null)
-                {
-                    logger.LogWarning("InventoryManager is not available");
-                    return ETaskResult.StillRunning;
-                }
-
-                int itemCount = inventoryManager->GetInventoryItemCount(ItemId);
-                if (itemCount == _itemCount)
-                {
-                    // TODO Better handling for game-provided errors, i.e. reacting to the 'Could not use' messages. UseItem() is successful in this case (and returns 0)
-                    logger.LogInformation(
-                        "Attempted to use vesper bay aetheryte ticket, but it didn't consume an item - reattempting next frame");
-                    _usedItem = false;
-                    return ETaskResult.StillRunning;
-                }
-            }
-
-            if (!_usedItem)
-            {
-                _usedItem = UseItem();
-                _continueAt = DateTime.Now.AddSeconds(2);
-                return ETaskResult.StillRunning;
-            }
-
-            return ETaskResult.TaskComplete;
-        }
-    }
-
-
-    internal sealed class UseOnGround(GameFunctions gameFunctions, ILogger<UseOnGround> logger) : UseItemBase(logger)
-    {
-        public uint DataId { get; set; }
-
-        public ITask With(uint dataId, uint itemId)
-        {
-            DataId = dataId;
-            ItemId = itemId;
-            return this;
-        }
-
-        protected override bool UseItem() => gameFunctions.UseItemOnGround(DataId, ItemId);
-
-        public override string ToString() => $"UseItem({ItemId} on ground at {DataId})";
-    }
-
-    internal sealed class UseOnObject(GameFunctions gameFunctions, ILogger<UseOnObject> logger) : UseItemBase(logger)
-    {
-        public uint DataId { get; set; }
-
-        public ITask With(uint dataId, uint itemId)
-        {
-            DataId = dataId;
-            ItemId = itemId;
-            return this;
-        }
-
-        protected override bool UseItem() => gameFunctions.UseItem(DataId, ItemId);
-
-        public override string ToString() => $"UseItem({ItemId} on {DataId})";
-    }
-
-    internal sealed class Use(GameFunctions gameFunctions, ILogger<Use> logger) : UseItemBase(logger)
-    {
-        public ITask With(uint itemId)
-        {
-            ItemId = itemId;
-            return this;
-        }
-
-        protected override bool UseItem() => gameFunctions.UseItem(ItemId);
-
-        public override string ToString() => $"UseItem({ItemId})";
-    }
-}
diff --git a/Questionable/Controller/Steps/Interactions/Action.cs b/Questionable/Controller/Steps/Interactions/Action.cs
new file mode 100644 (file)
index 0000000..75a40b3
--- /dev/null
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Generic;
+using Dalamud.Game.ClientState.Conditions;
+using Dalamud.Game.ClientState.Objects.Types;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Controller.Steps.Common;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class Action
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.Action)
+                return [];
+
+            ArgumentNullException.ThrowIfNull(step.DataId);
+            ArgumentNullException.ThrowIfNull(step.Action);
+
+            var unmount = serviceProvider.GetRequiredService<UnmountTask>();
+            var task = serviceProvider.GetRequiredService<UseOnObject>()
+                .With(step.DataId.Value, step.Action.Value);
+            return [unmount, task];
+        }
+
+        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new InvalidOperationException();
+    }
+
+    internal sealed class UseOnObject(GameFunctions gameFunctions, ILogger<UseOnObject> logger) : ITask
+    {
+        private bool _usedAction;
+        private DateTime _continueAt = DateTime.MinValue;
+
+        public uint DataId { get; set; }
+        public EAction Action { get; set; }
+
+        public ITask With(uint dataId, EAction action)
+        {
+            DataId = dataId;
+            Action = action;
+            return this;
+        }
+
+        public bool Start()
+        {
+            IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
+            if (gameObject == null)
+            {
+                logger.LogWarning("No game object with dataId {DataId}", DataId);
+                return false;
+            }
+
+            if (gameObject.IsTargetable)
+            {
+                _usedAction = gameFunctions.UseAction(gameObject, Action);
+                _continueAt = DateTime.Now.AddSeconds(0.5);
+                return true;
+            }
+
+            return true;
+        }
+
+        public ETaskResult Update()
+        {
+            if (DateTime.Now <= _continueAt)
+                return ETaskResult.StillRunning;
+
+            if (!_usedAction)
+            {
+                IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
+                if (gameObject == null || !gameObject.IsTargetable)
+                    return ETaskResult.StillRunning;
+
+                _usedAction = gameFunctions.UseAction(gameObject, Action);
+                _continueAt = DateTime.Now.AddSeconds(0.5);
+                return ETaskResult.StillRunning;
+            }
+
+            return ETaskResult.TaskComplete;
+        }
+
+        public override string ToString() => $"Action({Action})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/AetherCurrent.cs b/Questionable/Controller/Steps/Interactions/AetherCurrent.cs
new file mode 100644 (file)
index 0000000..82b13d2
--- /dev/null
@@ -0,0 +1,68 @@
+using System;
+using Dalamud.Plugin.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Data;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class AetherCurrent
+{
+    internal sealed class Factory(IServiceProvider serviceProvider, AetherCurrentData aetherCurrentData, IChatGui chatGui) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.AttuneAetherCurrent)
+                return null;
+
+            ArgumentNullException.ThrowIfNull(step.DataId);
+            ArgumentNullException.ThrowIfNull(step.AetherCurrentId);
+
+            if (!aetherCurrentData.IsValidAetherCurrent(step.TerritoryId, step.AetherCurrentId.Value))
+            {
+                chatGui.PrintError($"[Questionable] Aether current with id {step.AetherCurrentId} is referencing an invalid aether current, will skip attunement");
+                return null;
+            }
+
+            return serviceProvider.GetRequiredService<DoAttune>()
+                .With(step.DataId.Value, step.AetherCurrentId.Value);
+        }
+    }
+
+    internal sealed class DoAttune(GameFunctions gameFunctions, ILogger<DoAttune> logger) : ITask
+    {
+        public uint DataId { get; set; }
+        public uint AetherCurrentId { get; set; }
+
+        public ITask With(uint dataId, uint aetherCurrentId)
+        {
+            DataId = dataId;
+            AetherCurrentId = aetherCurrentId;
+            return this;
+        }
+
+        public bool Start()
+        {
+            if (!gameFunctions.IsAetherCurrentUnlocked(AetherCurrentId))
+            {
+                logger.LogInformation("Attuning to aether current {AetherCurrentId} / {DataId}", AetherCurrentId,
+                    DataId);
+                gameFunctions.InteractWith(DataId);
+                return true;
+            }
+
+            logger.LogInformation("Already attuned to aether current {AetherCurrentId} / {DataId}", AetherCurrentId,
+                DataId);
+            return false;
+        }
+
+        public ETaskResult Update() =>
+            gameFunctions.IsAetherCurrentUnlocked(AetherCurrentId)
+                ? ETaskResult.TaskComplete
+                : ETaskResult.StillRunning;
+
+        public override string ToString() => $"AttuneAetherCurrent({AetherCurrentId})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/AethernetShard.cs b/Questionable/Controller/Steps/Interactions/AethernetShard.cs
new file mode 100644 (file)
index 0000000..059a2c3
--- /dev/null
@@ -0,0 +1,55 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class AethernetShard
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.AttuneAethernetShard)
+                return null;
+
+            ArgumentNullException.ThrowIfNull(step.DataId);
+
+            return serviceProvider.GetRequiredService<DoAttune>()
+                .With((EAetheryteLocation)step.DataId);
+        }
+    }
+
+    internal sealed class DoAttune(GameFunctions gameFunctions, ILogger<DoAttune> logger) : ITask
+    {
+        public EAetheryteLocation AetheryteLocation { get; set; }
+
+        public ITask? With(EAetheryteLocation aetheryteLocation)
+        {
+            AetheryteLocation = aetheryteLocation;
+            return this;
+        }
+
+        public bool Start()
+        {
+            if (!gameFunctions.IsAetheryteUnlocked(AetheryteLocation))
+            {
+                logger.LogInformation("Attuning to aethernet shard {AethernetShard}", AetheryteLocation);
+                gameFunctions.InteractWith((uint)AetheryteLocation);
+                return true;
+            }
+
+            logger.LogInformation("Already attuned to aethernet shard {AethernetShard}", AetheryteLocation);
+            return false;
+        }
+
+        public ETaskResult Update() =>
+            gameFunctions.IsAetheryteUnlocked(AetheryteLocation)
+                ? ETaskResult.TaskComplete
+                : ETaskResult.StillRunning;
+
+        public override string ToString() => $"AttuneAethernetShard({AetheryteLocation})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/Aetheryte.cs b/Questionable/Controller/Steps/Interactions/Aetheryte.cs
new file mode 100644 (file)
index 0000000..6babf23
--- /dev/null
@@ -0,0 +1,55 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class Aetheryte
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.AttuneAetheryte)
+                return null;
+
+            ArgumentNullException.ThrowIfNull(step.DataId);
+
+            return serviceProvider.GetRequiredService<DoAttune>()
+                .With((EAetheryteLocation)step.DataId.Value);
+        }
+    }
+
+    internal sealed class DoAttune(GameFunctions gameFunctions, ILogger<DoAttune> logger) : ITask
+    {
+        public EAetheryteLocation AetheryteLocation { get; set; }
+
+        public ITask With(EAetheryteLocation aetheryteLocation)
+        {
+            AetheryteLocation = aetheryteLocation;
+            return this;
+        }
+
+        public bool Start()
+        {
+            if (!gameFunctions.IsAetheryteUnlocked(AetheryteLocation))
+            {
+                logger.LogInformation("Attuning to aetheryte {Aetheryte}", AetheryteLocation);
+                gameFunctions.InteractWith((uint)AetheryteLocation);
+                return true;
+            }
+
+            logger.LogInformation("Already attuned to aetheryte {Aetheryte}", AetheryteLocation);
+            return false;
+        }
+
+        public ETaskResult Update() =>
+            gameFunctions.IsAetheryteUnlocked(AetheryteLocation)
+                ? ETaskResult.TaskComplete
+                : ETaskResult.StillRunning;
+
+        public override string ToString() => $"AttuneAetheryte({AetheryteLocation})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/Combat.cs b/Questionable/Controller/Steps/Interactions/Combat.cs
new file mode 100644 (file)
index 0000000..5a868bc
--- /dev/null
@@ -0,0 +1,47 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.DependencyInjection;
+using Questionable.Controller.Steps.Common;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class Combat
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.Combat)
+                return [];
+
+            ArgumentNullException.ThrowIfNull(step.EnemySpawnType);
+
+            var unmount = serviceProvider.GetRequiredService<UnmountTask>();
+            if (step.EnemySpawnType == EEnemySpawnType.AfterInteraction)
+            {
+                ArgumentNullException.ThrowIfNull(step.DataId);
+
+                var task = serviceProvider.GetRequiredService<Interact.DoInteract>()
+                    .With(step.DataId.Value, true);
+                return [unmount, task];
+            }
+            else if (step.EnemySpawnType == EEnemySpawnType.AfterItemUse)
+            {
+                ArgumentNullException.ThrowIfNull(step.DataId);
+                ArgumentNullException.ThrowIfNull(step.ItemId);
+
+                var task = serviceProvider.GetRequiredService<UseItem.UseOnObject>()
+                    .With(step.DataId.Value, step.ItemId.Value);
+                return [unmount, task];
+            }
+            else
+                // automatically triggered when entering area, i.e. only unmount
+                return [unmount];
+        }
+
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new InvalidOperationException();
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/Duty.cs b/Questionable/Controller/Steps/Interactions/Duty.cs
new file mode 100644 (file)
index 0000000..a7de058
--- /dev/null
@@ -0,0 +1,49 @@
+using System;
+using Dalamud.Game.ClientState.Conditions;
+using Dalamud.Plugin.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class Duty
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.Duty)
+                return null;
+
+            ArgumentNullException.ThrowIfNull(step.ContentFinderConditionId);
+
+            return serviceProvider.GetRequiredService<OpenDutyFinder>()
+                .With(step.ContentFinderConditionId.Value);
+        }
+    }
+
+    internal sealed class OpenDutyFinder(GameFunctions gameFunctions, ICondition condition) : ITask
+    {
+        public uint ContentFinderConditionId { get; set; }
+
+        public ITask With(uint contentFinderConditionId)
+        {
+            ContentFinderConditionId = contentFinderConditionId;
+            return this;
+        }
+
+        public bool Start()
+        {
+            if (condition[ConditionFlag.InDutyQueue])
+                return false;
+
+            gameFunctions.OpenDutyFinder(ContentFinderConditionId);
+            return true;
+        }
+
+        public ETaskResult Update() => ETaskResult.TaskComplete;
+
+        public override string ToString() => $"OpenDutyFinder({ContentFinderConditionId})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/Emote.cs b/Questionable/Controller/Steps/Interactions/Emote.cs
new file mode 100644 (file)
index 0000000..4447847
--- /dev/null
@@ -0,0 +1,82 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.DependencyInjection;
+using Questionable.Controller.Steps.Common;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class Emote
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType is EInteractionType.AcceptQuest or EInteractionType.CompleteQuest)
+            {
+                if (step.Emote == null)
+                    return [];
+            }
+            else if (step.InteractionType != EInteractionType.Emote)
+                return [];
+
+            ArgumentNullException.ThrowIfNull(step.Emote);
+
+            var unmount = serviceProvider.GetRequiredService<UnmountTask>();
+            if (step.DataId != null)
+            {
+                var task = serviceProvider.GetRequiredService<UseOnObject>().With(step.Emote.Value, step.DataId.Value);
+                return [unmount, task];
+            }
+            else
+            {
+                var task = serviceProvider.GetRequiredService<Use>().With(step.Emote.Value);
+                return [unmount, task];
+            }
+        }
+
+        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new InvalidOperationException();
+    }
+
+    internal sealed class UseOnObject(ChatFunctions chatFunctions) : AbstractDelayedTask
+    {
+        public EEmote Emote { get; set; }
+        public uint DataId { get; set; }
+
+        public ITask With(EEmote emote, uint dataId)
+        {
+            Emote = emote;
+            DataId = dataId;
+            return this;
+        }
+
+        protected override bool StartInternal()
+        {
+            chatFunctions.UseEmote(DataId, Emote);
+            return true;
+        }
+
+        public override string ToString() => $"Emote({Emote} on {DataId})";
+    }
+
+    internal sealed class Use(ChatFunctions chatFunctions) : AbstractDelayedTask
+    {
+        public EEmote Emote { get; set; }
+
+        public ITask With(EEmote emote)
+        {
+            Emote = emote;
+            return this;
+        }
+
+        protected override bool StartInternal()
+        {
+            chatFunctions.UseEmote(Emote);
+            return true;
+        }
+
+        public override string ToString() => $"Emote({Emote})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/EquipItem.cs b/Questionable/Controller/Steps/Interactions/EquipItem.cs
new file mode 100644 (file)
index 0000000..65147b6
--- /dev/null
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Dalamud.Plugin.Services;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using Lumina.Excel.GeneratedSheets;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Controller.Steps.Common;
+using Questionable.Model.V1;
+using Quest = Questionable.Model.Quest;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class EquipItem
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.EquipItem)
+                return null;
+
+            ArgumentNullException.ThrowIfNull(step.ItemId);
+            return serviceProvider.GetRequiredService<DoEquip>()
+                .With(step.ItemId.Value);
+        }
+    }
+
+    internal sealed class DoEquip(IDataManager dataManager, ILogger<DoEquip> logger) : ITask
+    {
+        private static readonly IReadOnlyList<InventoryType> SourceInventoryTypes =
+        [
+            InventoryType.ArmoryMainHand,
+            InventoryType.ArmoryOffHand,
+            InventoryType.ArmoryHead,
+            InventoryType.ArmoryBody,
+            InventoryType.ArmoryHands,
+            InventoryType.ArmoryLegs,
+            InventoryType.ArmoryFeets,
+
+            InventoryType.ArmoryEar,
+            InventoryType.ArmoryNeck,
+            InventoryType.ArmoryWrist,
+            InventoryType.ArmoryRings,
+
+            InventoryType.Inventory1,
+            InventoryType.Inventory2,
+            InventoryType.Inventory3,
+            InventoryType.Inventory4,
+        ];
+
+        private uint _itemId;
+        private Item _item = null!;
+        private List<ushort> _targetSlots = [];
+
+        private DateTime _continueAt = DateTime.MaxValue;
+
+        public ITask With(uint itemId)
+        {
+            _itemId = itemId;
+            _item = dataManager.GetExcelSheet<Item>()!.GetRow(itemId) ??
+                    throw new ArgumentOutOfRangeException(nameof(itemId));
+            _targetSlots = GetEquipSlot(_item) ?? throw new InvalidOperationException("Not a piece of equipment");
+            return this;
+        }
+
+        public bool Start()
+        {
+            Equip();
+            _continueAt = DateTime.Now.AddSeconds(1);
+            return true;
+        }
+
+        public unsafe ETaskResult Update()
+        {
+            if (DateTime.Now < _continueAt)
+                return ETaskResult.StillRunning;
+
+            InventoryManager* inventoryManager = InventoryManager.Instance();
+            if (inventoryManager == null)
+                return ETaskResult.StillRunning;
+
+            if (_targetSlots.Any(x =>
+                    inventoryManager->GetInventorySlot(InventoryType.EquippedItems, x)->ItemId == _itemId))
+                return ETaskResult.TaskComplete;
+
+            Equip();
+            _continueAt = DateTime.Now.AddSeconds(1);
+            return ETaskResult.StillRunning;
+        }
+
+        private unsafe bool Equip()
+        {
+            var inventoryManager = InventoryManager.Instance();
+            if (inventoryManager == null)
+                return false;
+
+            var equippedContainer = inventoryManager->GetInventoryContainer(InventoryType.EquippedItems);
+            if (equippedContainer == null)
+                return false;
+
+            if (_targetSlots.Any(slot => equippedContainer->GetInventorySlot(slot)->ItemId == _itemId))
+            {
+                logger.LogInformation("Already equipped {Item}, skipping step", _item.Name?.ToString());
+                return false;
+            }
+
+            foreach (InventoryType sourceInventoryType in SourceInventoryTypes)
+            {
+                var sourceContainer = inventoryManager->GetInventoryContainer(sourceInventoryType);
+                if (sourceContainer == null)
+                    continue;
+
+                if (inventoryManager->GetItemCountInContainer(_itemId, sourceInventoryType, true) == 0 &&
+                    inventoryManager->GetItemCountInContainer(_itemId, sourceInventoryType) == 0)
+                    continue;
+
+                for (ushort sourceSlot = 0; sourceSlot < sourceContainer->Size; sourceSlot++)
+                {
+                    var sourceItem = sourceContainer->GetInventorySlot(sourceSlot);
+                    if (sourceItem == null || sourceItem->ItemId != _itemId)
+                        continue;
+
+                    // Move the item to the first available slot
+                    ushort targetSlot = _targetSlots
+                        .Where(x => inventoryManager->GetInventorySlot(InventoryType.EquippedItems, x)->ItemId == 0)
+                        .Concat(_targetSlots).First();
+
+                    logger.LogInformation(
+                        "Equipping item from {SourceInventory}, {SourceSlot} to {TargetInventory}, {TargetSlot}",
+                        sourceInventoryType, sourceSlot, InventoryType.EquippedItems, targetSlot);
+
+                    int result = inventoryManager->MoveItemSlot(sourceInventoryType, sourceSlot,
+                        InventoryType.EquippedItems, targetSlot, 1);
+                    logger.LogInformation("MoveItemSlot result: {Result}", result);
+                    return result == 0;
+                }
+            }
+
+            return false;
+        }
+
+        private static List<ushort>? GetEquipSlot(Item item)
+        {
+            return item.EquipSlotCategory.Row switch
+            {
+                >= 1 and <= 11 => [(ushort)(item.EquipSlotCategory.Row - 1)],
+                12 => [11, 12], // rings
+                17 => [14], // soul crystal
+                _ => null
+            };
+        }
+
+        public override string ToString() => $"Equip({_item.Name})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/Interact.cs b/Questionable/Controller/Steps/Interactions/Interact.cs
new file mode 100644 (file)
index 0000000..f49394d
--- /dev/null
@@ -0,0 +1,130 @@
+using System;
+using System.Collections.Generic;
+using Dalamud.Game.ClientState.Conditions;
+using Dalamud.Game.ClientState.Objects.Enums;
+using Dalamud.Game.ClientState.Objects.Types;
+using Dalamud.Plugin.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Controller.Steps.Shared;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class Interact
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType is EInteractionType.AcceptQuest or EInteractionType.CompleteQuest)
+            {
+                if (step.Emote != null)
+                    yield break;
+            }
+            else if (step.InteractionType != EInteractionType.Interact)
+                yield break;
+
+            ArgumentNullException.ThrowIfNull(step.DataId);
+
+            // if we're fast enough, it is possible to get the smalltalk prompt
+            if (sequence.Sequence == 0 && sequence.Steps.IndexOf(step) == 0)
+                yield return serviceProvider.GetRequiredService<WaitAtEnd.WaitDelay>();
+
+            yield return serviceProvider.GetRequiredService<DoInteract>()
+                .With(step.DataId.Value, step.TargetTerritoryId != null);
+        }
+
+        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new InvalidOperationException();
+    }
+
+    internal sealed class DoInteract(GameFunctions gameFunctions, ICondition condition, ILogger<DoInteract> logger)
+        : ITask
+    {
+        private bool _needsUnmount;
+        private bool _interacted;
+        private DateTime _continueAt = DateTime.MinValue;
+
+        private uint DataId { get; set; }
+        private bool SkipMarkerCheck { get; set; }
+
+        public ITask With(uint dataId, bool skipMarkerCheck)
+        {
+            DataId = dataId;
+            SkipMarkerCheck = skipMarkerCheck;
+            return this;
+        }
+
+        public bool Start()
+        {
+            IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
+            if (gameObject == null)
+            {
+                logger.LogWarning("No game object with dataId {DataId}", DataId);
+                return false;
+            }
+
+            // this is only relevant for followers on quests
+            if (!gameObject.IsTargetable && condition[ConditionFlag.Mounted])
+            {
+                _needsUnmount = true;
+                gameFunctions.Unmount();
+                _continueAt = DateTime.Now.AddSeconds(1);
+                return true;
+            }
+
+            if (gameObject.IsTargetable && HasAnyMarker(gameObject))
+            {
+                _interacted = gameFunctions.InteractWith(DataId);
+                _continueAt = DateTime.Now.AddSeconds(0.5);
+                return true;
+            }
+
+            return true;
+        }
+
+        public ETaskResult Update()
+        {
+            if (DateTime.Now <= _continueAt)
+                return ETaskResult.StillRunning;
+
+            if (_needsUnmount)
+            {
+                if (condition[ConditionFlag.Mounted])
+                {
+                    gameFunctions.Unmount();
+                    _continueAt = DateTime.Now.AddSeconds(1);
+                    return ETaskResult.StillRunning;
+                }
+                else
+                    _needsUnmount = false;
+            }
+
+            if (!_interacted)
+            {
+                IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
+                if (gameObject == null || !gameObject.IsTargetable || !HasAnyMarker(gameObject))
+                    return ETaskResult.StillRunning;
+
+                _interacted = gameFunctions.InteractWith(DataId);
+                _continueAt = DateTime.Now.AddSeconds(0.5);
+                return ETaskResult.StillRunning;
+            }
+
+            return ETaskResult.TaskComplete;
+        }
+
+        private unsafe bool HasAnyMarker(IGameObject gameObject)
+        {
+            if (SkipMarkerCheck || gameObject.ObjectKind != ObjectKind.EventNpc)
+                return true;
+
+            var gameObjectStruct = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)gameObject.Address;
+            return gameObjectStruct->NamePlateIconId != 0;
+        }
+
+        public override string ToString() => $"Interact({DataId})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/Jump.cs b/Questionable/Controller/Steps/Interactions/Jump.cs
new file mode 100644 (file)
index 0000000..8c5c9a8
--- /dev/null
@@ -0,0 +1,77 @@
+using System;
+using Dalamud.Plugin.Services;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using Microsoft.Extensions.DependencyInjection;
+using Questionable.Controller.Steps.Common;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class Jump
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.Jump)
+                return null;
+
+            ArgumentNullException.ThrowIfNull(step.JumpDestination);
+
+            return serviceProvider.GetRequiredService<DoJump>()
+                .With(step.DataId, step.JumpDestination, step.Comment);
+        }
+    }
+
+    internal sealed class DoJump(
+        MovementController movementController,
+        IClientState clientState,
+        IFramework framework) : ITask
+    {
+        public uint? DataId { get; set; }
+        public JumpDestination JumpDestination { get; set; } = null!;
+        public string? Comment { get; set; }
+
+        public ITask With(uint? dataId, JumpDestination jumpDestination, string? comment)
+        {
+            DataId = dataId;
+            JumpDestination = jumpDestination;
+            Comment = comment ?? string.Empty;
+            return this;
+        }
+
+        public bool Start()
+        {
+            float stopDistance = JumpDestination.StopDistance ?? 1f;
+            if ((clientState.LocalPlayer!.Position - JumpDestination.Position).Length() <= stopDistance)
+                return false;
+
+            movementController.NavigateTo(EMovementType.Quest, DataId, [JumpDestination.Position], false, false,
+                JumpDestination.StopDistance ?? stopDistance);
+            framework.RunOnTick(() =>
+                {
+                    unsafe
+                    {
+                        ActionManager.Instance()->UseAction(ActionType.GeneralAction, 2);
+                    }
+                },
+                TimeSpan.FromSeconds(JumpDestination.DelaySeconds ?? 0.5f));
+            return true;
+        }
+
+        public ETaskResult Update()
+        {
+            if (movementController.IsPathfinding || movementController.IsPathRunning)
+                return ETaskResult.StillRunning;
+
+            DateTime movementStartedAt = movementController.MovementStartedAt;
+            if (movementStartedAt == DateTime.MaxValue || movementStartedAt.AddSeconds(2) >= DateTime.Now)
+                return ETaskResult.StillRunning;
+
+            return ETaskResult.TaskComplete;
+        }
+
+        public override string ToString() => $"Jump({Comment})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/Say.cs b/Questionable/Controller/Steps/Interactions/Say.cs
new file mode 100644 (file)
index 0000000..c833bad
--- /dev/null
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.DependencyInjection;
+using Questionable.Controller.Steps.Common;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class Say
+{
+    internal sealed class Factory(IServiceProvider serviceProvider, GameFunctions gameFunctions) : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.Say)
+                return [];
+
+
+            ArgumentNullException.ThrowIfNull(step.ChatMessage);
+
+            string? excelString =
+                gameFunctions.GetDialogueText(quest, step.ChatMessage.ExcelSheet, step.ChatMessage.Key);
+            ArgumentNullException.ThrowIfNull(excelString);
+
+            var unmount = serviceProvider.GetRequiredService<UnmountTask>();
+            var task = serviceProvider.GetRequiredService<UseChat>().With(excelString);
+            return [unmount, task];
+        }
+
+        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new InvalidOperationException();
+    }
+
+    internal sealed class UseChat(ChatFunctions chatFunctions) : AbstractDelayedTask
+    {
+        public string ChatMessage { get; set; } = null!;
+
+        public ITask With(string chatMessage)
+        {
+            ChatMessage = chatMessage;
+            return this;
+        }
+
+        protected override bool StartInternal()
+        {
+            chatFunctions.ExecuteCommand($"/say {ChatMessage}");
+            return true;
+        }
+
+        public override string ToString() => $"Say({ChatMessage})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs
new file mode 100644 (file)
index 0000000..f7d5172
--- /dev/null
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using Dalamud.Plugin.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Questionable.External;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class SinglePlayerDuty
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.SinglePlayerDuty)
+                return [];
+
+            ArgumentNullException.ThrowIfNull(step.DataId);
+            return
+            [
+                serviceProvider.GetRequiredService<DisableYesAlready>(),
+                serviceProvider.GetRequiredService<Interact.DoInteract>()
+                    .With(step.DataId.Value, true),
+                serviceProvider.GetRequiredService<RestoreYesAlready>()
+            ];
+        }
+
+        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new InvalidOperationException();
+    }
+
+    internal sealed class DisableYesAlready(YesAlreadyIpc yesAlreadyIpc) : ITask
+    {
+        public bool Start()
+        {
+            yesAlreadyIpc.DisableYesAlready();
+            return true;
+        }
+
+        public ETaskResult Update() => ETaskResult.TaskComplete;
+
+        public override string ToString() => "DisableYA";
+    }
+
+    internal sealed class RestoreYesAlready(YesAlreadyIpc yesAlreadyIpc, IGameGui gameGui) : ITask
+    {
+        public bool Start() => true;
+
+        public ETaskResult Update()
+        {
+            if (gameGui.GetAddonByName("SelectYesno") != nint.Zero ||
+                gameGui.GetAddonByName("DifficultySelectYesNo") != nint.Zero)
+                return ETaskResult.StillRunning;
+
+            yesAlreadyIpc.RestoreYesAlready();
+            return ETaskResult.TaskComplete;
+        }
+
+        public override string ToString() => "Wait(DialogClosed) → RestoreYA";
+    }
+}
diff --git a/Questionable/Controller/Steps/Interactions/UseItem.cs b/Questionable/Controller/Steps/Interactions/UseItem.cs
new file mode 100644 (file)
index 0000000..05dbd72
--- /dev/null
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Controller.Steps.Common;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Interactions;
+
+internal static class UseItem
+{
+    public const int VesperBayAetheryteTicket = 30362;
+
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.UseItem)
+                return [];
+
+            ArgumentNullException.ThrowIfNull(step.ItemId);
+
+            var unmount = serviceProvider.GetRequiredService<UnmountTask>();
+            if (step.GroundTarget == true)
+            {
+                ArgumentNullException.ThrowIfNull(step.DataId);
+
+                var task = serviceProvider.GetRequiredService<UseOnGround>()
+                    .With(step.DataId.Value, step.ItemId.Value);
+                return [unmount, task];
+            }
+            else if (step.DataId != null)
+            {
+                var task = serviceProvider.GetRequiredService<UseOnObject>()
+                    .With(step.DataId.Value, step.ItemId.Value);
+                return [unmount, task];
+            }
+            else
+            {
+                var task = serviceProvider.GetRequiredService<Use>()
+                    .With(step.ItemId.Value);
+                return [unmount, task];
+            }
+        }
+
+        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new InvalidOperationException();
+    }
+
+    internal abstract class UseItemBase(ILogger logger) : ITask
+    {
+        private bool _usedItem;
+        private DateTime _continueAt;
+        private int _itemCount;
+
+        public uint ItemId { get; set; }
+
+        protected abstract bool UseItem();
+
+        public unsafe bool Start()
+        {
+            InventoryManager* inventoryManager = InventoryManager.Instance();
+            if (inventoryManager == null)
+                throw new TaskException("No InventoryManager");
+
+            _itemCount = inventoryManager->GetInventoryItemCount(ItemId);
+            if (_itemCount == 0)
+                throw new TaskException($"Don't have any {ItemId} in inventory (NQ only)");
+
+            _usedItem = UseItem();
+            if (ItemId == VesperBayAetheryteTicket)
+                _continueAt = DateTime.Now.AddSeconds(11);
+            else
+                _continueAt = DateTime.Now.AddSeconds(2);
+            return true;
+        }
+
+        public unsafe ETaskResult Update()
+        {
+            if (DateTime.Now <= _continueAt)
+                return ETaskResult.StillRunning;
+
+            if (ItemId == VesperBayAetheryteTicket)
+            {
+                InventoryManager* inventoryManager = InventoryManager.Instance();
+                if (inventoryManager == null)
+                {
+                    logger.LogWarning("InventoryManager is not available");
+                    return ETaskResult.StillRunning;
+                }
+
+                int itemCount = inventoryManager->GetInventoryItemCount(ItemId);
+                if (itemCount == _itemCount)
+                {
+                    // TODO Better handling for game-provided errors, i.e. reacting to the 'Could not use' messages. UseItem() is successful in this case (and returns 0)
+                    logger.LogInformation(
+                        "Attempted to use vesper bay aetheryte ticket, but it didn't consume an item - reattempting next frame");
+                    _usedItem = false;
+                    return ETaskResult.StillRunning;
+                }
+            }
+
+            if (!_usedItem)
+            {
+                _usedItem = UseItem();
+                _continueAt = DateTime.Now.AddSeconds(2);
+                return ETaskResult.StillRunning;
+            }
+
+            return ETaskResult.TaskComplete;
+        }
+    }
+
+
+    internal sealed class UseOnGround(GameFunctions gameFunctions, ILogger<UseOnGround> logger) : UseItemBase(logger)
+    {
+        public uint DataId { get; set; }
+
+        public ITask With(uint dataId, uint itemId)
+        {
+            DataId = dataId;
+            ItemId = itemId;
+            return this;
+        }
+
+        protected override bool UseItem() => gameFunctions.UseItemOnGround(DataId, ItemId);
+
+        public override string ToString() => $"UseItem({ItemId} on ground at {DataId})";
+    }
+
+    internal sealed class UseOnObject(GameFunctions gameFunctions, ILogger<UseOnObject> logger) : UseItemBase(logger)
+    {
+        public uint DataId { get; set; }
+
+        public ITask With(uint dataId, uint itemId)
+        {
+            DataId = dataId;
+            ItemId = itemId;
+            return this;
+        }
+
+        protected override bool UseItem() => gameFunctions.UseItem(DataId, ItemId);
+
+        public override string ToString() => $"UseItem({ItemId} on {DataId})";
+    }
+
+    internal sealed class Use(GameFunctions gameFunctions, ILogger<Use> logger) : UseItemBase(logger)
+    {
+        public ITask With(uint itemId)
+        {
+            ItemId = itemId;
+            return this;
+        }
+
+        protected override bool UseItem() => gameFunctions.UseItem(ItemId);
+
+        public override string ToString() => $"UseItem({ItemId})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Shared/AethernetShortcut.cs b/Questionable/Controller/Steps/Shared/AethernetShortcut.cs
new file mode 100644 (file)
index 0000000..7e06068
--- /dev/null
@@ -0,0 +1,155 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using Dalamud.Plugin.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Data;
+using Questionable.External;
+using Questionable.Model;
+using Questionable.Model.V1;
+using Questionable.Model.V1.Converter;
+
+namespace Questionable.Controller.Steps.Shared;
+
+internal static class AethernetShortcut
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.AethernetShortcut == null)
+                return null;
+
+            return serviceProvider.GetRequiredService<UseAethernetShortcut>()
+                .With(step.AethernetShortcut.From, step.AethernetShortcut.To);
+        }
+    }
+
+    internal sealed class UseAethernetShortcut(
+        ILogger<UseAethernetShortcut> logger,
+        GameFunctions gameFunctions,
+        IClientState clientState,
+        AetheryteData aetheryteData,
+        LifestreamIpc lifestreamIpc,
+        MovementController movementController) : ISkippableTask
+    {
+        private bool _moving;
+        private bool _teleported;
+
+        public EAetheryteLocation From { get; set; }
+        public EAetheryteLocation To { get; set; }
+
+        public ITask With(EAetheryteLocation from, EAetheryteLocation to)
+        {
+            From = from;
+            To = to;
+            return this;
+        }
+
+        public bool Start()
+        {
+            if (gameFunctions.IsAetheryteUnlocked(From) &&
+                gameFunctions.IsAetheryteUnlocked(To))
+            {
+                ushort territoryType = clientState.TerritoryType;
+                Vector3 playerPosition = clientState.LocalPlayer!.Position;
+
+                // closer to the source
+                if (aetheryteData.CalculateDistance(playerPosition, territoryType, From) <
+                    aetheryteData.CalculateDistance(playerPosition, territoryType, To))
+                {
+                    if (aetheryteData.CalculateDistance(playerPosition, territoryType, From) < 11)
+                    {
+                        logger.LogInformation("Using lifestream to teleport to {Destination}", To);
+                        lifestreamIpc.Teleport(To);
+
+                        _teleported = true;
+                        return true;
+                    }
+                    else if (From == EAetheryteLocation.SolutionNine)
+                    {
+                        logger.LogInformation("Moving to S9 aetheryte");
+                        List<Vector3> nearbyPoints =
+                        [
+                            new(7.225532f, 8.467899f, -7.1670876f),
+                            new(7.177844f, 8.467899f, 7.2216787f),
+                            new(-7.0762224f, 8.467898f, 7.1924725f),
+                            new(-7.1289554f, 8.467898f, -7.0594683f)
+                        ];
+
+                        Vector3 closestPoint = nearbyPoints.MinBy(x => (playerPosition - x).Length());
+                        _moving = true;
+                        movementController.NavigateTo(EMovementType.Quest, (uint)From, closestPoint, false, true,
+                            0.25f);
+                        return true;
+                    }
+                    else
+                    {
+                        logger.LogInformation("Moving to aethernet shortcut");
+                        _moving = true;
+                        movementController.NavigateTo(EMovementType.Quest, (uint)From, aetheryteData.Locations[From],
+                            false, true,
+                            AetheryteConverter.IsLargeAetheryte(From) ? 10.9f : 6.9f);
+                        return true;
+                    }
+                }
+            }
+            else
+                logger.LogWarning(
+                    "Aethernet shortcut not unlocked (from: {FromAetheryte}, to: {ToAetheryte}), walking manually",
+                    From, To);
+
+            return false;
+        }
+
+        public ETaskResult Update()
+        {
+            if (_moving)
+            {
+                var movementStartedAt = movementController.MovementStartedAt;
+                if (movementStartedAt == DateTime.MaxValue || movementStartedAt.AddSeconds(2) >= DateTime.Now)
+                    return ETaskResult.StillRunning;
+
+                if (!movementController.IsPathfinding && !movementController.IsPathRunning)
+                    _moving = false;
+
+                return ETaskResult.StillRunning;
+            }
+
+            if (!_teleported)
+            {
+                logger.LogInformation("Using lifestream to teleport to {Destination}", To);
+                lifestreamIpc.Teleport(To);
+
+                _teleported = true;
+                return ETaskResult.StillRunning;
+            }
+
+            if (aetheryteData.IsAirshipLanding(To))
+            {
+                if (aetheryteData.CalculateAirshipLandingDistance(clientState.LocalPlayer?.Position ?? Vector3.Zero,
+                        clientState.TerritoryType, To) > 5)
+                    return ETaskResult.StillRunning;
+            }
+            else if (aetheryteData.IsCityAetheryte(To))
+            {
+                if (aetheryteData.CalculateDistance(clientState.LocalPlayer?.Position ?? Vector3.Zero,
+                        clientState.TerritoryType, To) > 20)
+                    return ETaskResult.StillRunning;
+            }
+            else
+            {
+                // some overworld location (e.g. 'Tesselation (Lakeland)' would end up here
+                if (clientState.TerritoryType != aetheryteData.TerritoryIds[To])
+                    return ETaskResult.StillRunning;
+            }
+
+
+            return ETaskResult.TaskComplete;
+        }
+
+        public override string ToString() => $"UseAethernet({From} -> {To})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Shared/AetheryteShortcut.cs b/Questionable/Controller/Steps/Shared/AetheryteShortcut.cs
new file mode 100644 (file)
index 0000000..e1fb0f2
--- /dev/null
@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using Dalamud.Plugin.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Controller.Steps.Common;
+using Questionable.Data;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Shared;
+
+internal static class AetheryteShortcut
+{
+    internal sealed class Factory(
+        IServiceProvider serviceProvider,
+        GameFunctions gameFunctions,
+        AetheryteData aetheryteData) : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.AetheryteShortcut == null)
+                return [];
+
+            var task = serviceProvider.GetRequiredService<UseAetheryteShortcut>()
+                .With(step, step.AetheryteShortcut.Value, aetheryteData.TerritoryIds[step.AetheryteShortcut.Value]);
+            return
+            [
+                new WaitConditionTask(() => gameFunctions.CanTeleport(step.AetheryteShortcut.Value), "CanTeleport"),
+                task
+            ];
+        }
+
+        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new InvalidOperationException();
+    }
+
+    internal sealed class UseAetheryteShortcut(
+        ILogger<UseAetheryteShortcut> logger,
+        GameFunctions gameFunctions,
+        IClientState clientState,
+        IChatGui chatGui,
+        AetheryteData aetheryteData) : ISkippableTask
+    {
+        private DateTime _continueAt;
+
+        public QuestStep Step { get; set; } = null!;
+        public EAetheryteLocation TargetAetheryte { get; set; }
+
+        /// <summary>
+        /// If using an aethernet shortcut after, the aetheryte's territory-id and the step's territory-id can differ,
+        /// we always use the aetheryte's territory-id.
+        /// </summary>
+        public ushort ExpectedTerritoryId { get; set; }
+
+        public ITask With(QuestStep step, EAetheryteLocation targetAetheryte, ushort expectedTerritoryId)
+        {
+            Step = step;
+            TargetAetheryte = targetAetheryte;
+            ExpectedTerritoryId = expectedTerritoryId;
+            return this;
+        }
+
+        public bool Start()
+        {
+            _continueAt = DateTime.Now.AddSeconds(8);
+            ushort territoryType = clientState.TerritoryType;
+            if (ExpectedTerritoryId == territoryType)
+            {
+                if (Step.SkipIf.Contains(ESkipCondition.AetheryteShortcutIfInSameTerritory))
+                {
+                    logger.LogInformation("Skipping aetheryte teleport due to SkipIf");
+                    return false;
+                }
+
+                Vector3 pos = clientState.LocalPlayer!.Position;
+                if (Step.Position != null && (pos - Step.Position.Value).Length() < Step.CalculateActualStopDistance())
+                {
+                    logger.LogInformation("Skipping aetheryte teleport, we're near the target");
+                    return false;
+                }
+
+                if (aetheryteData.CalculateDistance(pos, territoryType, TargetAetheryte) < 20 ||
+                    (Step.AethernetShortcut != null &&
+                     (aetheryteData.CalculateDistance(pos, territoryType, Step.AethernetShortcut.From) < 20 ||
+                      aetheryteData.CalculateDistance(pos, territoryType, Step.AethernetShortcut.To) < 20)))
+                {
+                    logger.LogInformation("Skipping aetheryte teleport");
+                    return false;
+                }
+            }
+
+            if (!gameFunctions.IsAetheryteUnlocked(TargetAetheryte))
+            {
+                chatGui.Print($"[Questionable] Aetheryte {TargetAetheryte} is not unlocked.");
+                throw new TaskException("Aetheryte is not unlocked");
+            }
+            else if (gameFunctions.TeleportAetheryte(TargetAetheryte))
+            {
+                logger.LogInformation("Travelling via aetheryte...");
+                return true;
+            }
+            else
+            {
+                chatGui.Print("[Questionable] Unable to teleport to aetheryte.");
+                throw new TaskException("Unable to teleport to aetheryte");
+            }
+        }
+
+        public ETaskResult Update()
+        {
+            if (DateTime.Now >= _continueAt && clientState.TerritoryType == ExpectedTerritoryId)
+                return ETaskResult.TaskComplete;
+
+            return ETaskResult.StillRunning;
+        }
+
+        public override string ToString() => $"UseAetheryte({TargetAetheryte})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Shared/Move.cs b/Questionable/Controller/Steps/Shared/Move.cs
new file mode 100644 (file)
index 0000000..91812a3
--- /dev/null
@@ -0,0 +1,178 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Numerics;
+using Dalamud.Game.ClientState.Objects.Types;
+using Dalamud.Plugin.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Controller.Steps.Common;
+using Questionable.Data;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Shared;
+
+internal static class Move
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.Position != null)
+            {
+                var builder = serviceProvider.GetRequiredService<MoveBuilder>();
+                builder.Step = step;
+                builder.Destination = step.Position.Value;
+                return builder.Build();
+            }
+            else if (step is { DataId: not null, StopDistance: not null })
+            {
+                var task = serviceProvider.GetRequiredService<ExpectToBeNearDataId>();
+                task.DataId = step.DataId.Value;
+                task.StopDistance = step.StopDistance.Value;
+                return [task];
+            }
+
+            return [];
+        }
+
+        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new InvalidOperationException();
+    }
+
+    internal sealed class MoveBuilder(
+        IServiceProvider serviceProvider,
+        ILogger<MoveBuilder> logger,
+        GameFunctions gameFunctions,
+        IClientState clientState,
+        MovementController movementController,
+        TerritoryData territoryData)
+    {
+        public QuestStep Step { get; set; } = null!;
+        public Vector3 Destination { get; set; }
+
+        public IEnumerable<ITask> Build()
+        {
+            if (Step.InteractionType == EInteractionType.Jump && Step.JumpDestination != null &&
+                (clientState.LocalPlayer!.Position - Step.JumpDestination.Position).Length() <=
+                (Step.JumpDestination.StopDistance ?? 1f))
+            {
+                logger.LogInformation("We're at the jump destination, skipping movement");
+                yield break;
+            }
+
+            yield return new WaitConditionTask(() => clientState.TerritoryType == Step.TerritoryId,
+                $"Wait(territory: {territoryData.GetNameAndId(Step.TerritoryId)})");
+
+            if (!Step.DisableNavmesh)
+                yield return new WaitConditionTask(() => movementController.IsNavmeshReady,
+                    "Wait(navmesh ready)");
+
+            float distance = Step.CalculateActualStopDistance();
+            var position = clientState.LocalPlayer?.Position ?? new Vector3();
+            float actualDistance = (position - Destination).Length();
+
+            if (Step.Mount == true)
+                yield return serviceProvider.GetRequiredService<MountTask>()
+                    .With(Step.TerritoryId, MountTask.EMountIf.Always);
+            else if (Step.Mount == false)
+                yield return serviceProvider.GetRequiredService<UnmountTask>();
+
+            if (!Step.DisableNavmesh)
+            {
+                if (Step.Mount == null)
+                {
+                    MountTask.EMountIf mountIf =
+                        actualDistance > distance && Step.Fly == true &&
+                        gameFunctions.IsFlyingUnlocked(Step.TerritoryId)
+                            ? MountTask.EMountIf.Always
+                            : MountTask.EMountIf.AwayFromPosition;
+                    yield return serviceProvider.GetRequiredService<MountTask>()
+                        .With(Step.TerritoryId, mountIf, Destination);
+                }
+
+                if (actualDistance > distance)
+                {
+                    yield return serviceProvider.GetRequiredService<MoveInternal>()
+                        .With(Destination, m =>
+                        {
+                            m.NavigateTo(EMovementType.Quest, Step.DataId, Destination,
+                                fly: Step.Fly == true && gameFunctions.IsFlyingUnlocked(Step.TerritoryId),
+                                sprint: Step.Sprint != false,
+                                stopDistance: distance);
+                        });
+                }
+            }
+            else
+            {
+                // navmesh won't move close enough
+                if (actualDistance > distance)
+                {
+                    yield return serviceProvider.GetRequiredService<MoveInternal>()
+                        .With(Destination, m =>
+                        {
+                            m.NavigateTo(EMovementType.Quest, Step.DataId, [Destination],
+                                fly: Step.Fly == true && gameFunctions.IsFlyingUnlockedInCurrentZone(),
+                                sprint: Step.Sprint != false,
+                                stopDistance: distance);
+                        });
+                }
+            }
+        }
+    }
+
+    internal sealed class MoveInternal(MovementController movementController, ILogger<MoveInternal> logger) : ITask
+    {
+        public Action<MovementController> StartAction { get; set; } = null!;
+        public Vector3 Destination { get; set; }
+
+        public ITask With(Vector3 destination, Action<MovementController> startAction)
+        {
+            Destination = destination;
+            StartAction = startAction;
+            return this;
+        }
+
+        public bool Start()
+        {
+            logger.LogInformation("Moving to {Destination}", Destination.ToString("G", CultureInfo.InvariantCulture));
+            StartAction(movementController);
+            return true;
+        }
+
+        public ETaskResult Update()
+        {
+            if (movementController.IsPathfinding || movementController.IsPathRunning)
+                return ETaskResult.StillRunning;
+
+            DateTime movementStartedAt = movementController.MovementStartedAt;
+            if (movementStartedAt == DateTime.MaxValue || movementStartedAt.AddSeconds(2) >= DateTime.Now)
+                return ETaskResult.StillRunning;
+
+            return ETaskResult.TaskComplete;
+        }
+
+        public override string ToString() => $"MoveTo({Destination.ToString("G", CultureInfo.InvariantCulture)})";
+    }
+
+    internal sealed class ExpectToBeNearDataId(GameFunctions gameFunctions, IClientState clientState) : ITask
+    {
+        public uint DataId { get; set; }
+        public float StopDistance { get; set; }
+
+        public bool Start() => true;
+
+        public ETaskResult Update()
+        {
+            IGameObject? gameObject = gameFunctions.FindObjectByDataId(DataId);
+            if (gameObject == null ||
+                (gameObject.Position - clientState.LocalPlayer!.Position).Length() > StopDistance)
+            {
+                throw new TaskException("Object not found or too far away, no position so we can't move");
+            }
+
+            return ETaskResult.TaskComplete;
+        }
+    }
+}
diff --git a/Questionable/Controller/Steps/Shared/SkipCondition.cs b/Questionable/Controller/Steps/Shared/SkipCondition.cs
new file mode 100644 (file)
index 0000000..30feca4
--- /dev/null
@@ -0,0 +1,128 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Dalamud.Game.ClientState.Objects.Types;
+using Dalamud.Plugin.Services;
+using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
+using FFXIVClientStructs.FFXIV.Client.Game.UI;
+using FFXIVClientStructs.FFXIV.Client.System.Framework;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Shared;
+
+internal static class SkipCondition
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.SkipIf.Contains(ESkipCondition.Never))
+                return null;
+
+            var relevantConditions =
+                step.SkipIf.Where(x => x != ESkipCondition.AetheryteShortcutIfInSameTerritory).ToList();
+            if (relevantConditions.Count == 0 && step.CompletionQuestVariablesFlags.Count == 0)
+                return null;
+
+            return serviceProvider.GetRequiredService<CheckTask>()
+                .With(step, relevantConditions, quest.QuestId);
+        }
+    }
+
+    internal sealed class CheckTask(
+        ILogger<CheckTask> logger,
+        GameFunctions gameFunctions,
+        IClientState clientState) : ITask
+    {
+        public QuestStep Step { get; set; } = null!;
+        public List<ESkipCondition> SkipConditions { get; set; } = null!;
+        public ushort QuestId { get; set; }
+
+        public ITask With(QuestStep step, List<ESkipCondition> skipConditions, ushort questId)
+        {
+            Step = step;
+            SkipConditions = skipConditions;
+            QuestId = questId;
+            return this;
+        }
+
+        public unsafe bool Start()
+        {
+            logger.LogInformation("Checking skip conditions; {ConfiguredConditions}", string.Join(",", SkipConditions));
+
+            if (SkipConditions.Contains(ESkipCondition.FlyingUnlocked) &&
+                gameFunctions.IsFlyingUnlocked(Step.TerritoryId))
+            {
+                logger.LogInformation("Skipping step, as flying is unlocked");
+                return true;
+            }
+
+            if (SkipConditions.Contains(ESkipCondition.FlyingLocked) &&
+                !gameFunctions.IsFlyingUnlocked(Step.TerritoryId))
+            {
+                logger.LogInformation("Skipping step, as flying is locked");
+                return true;
+            }
+
+            if (SkipConditions.Contains(ESkipCondition.ChocoboUnlocked) &&
+                PlayerState.Instance()->IsMountUnlocked(1))
+            {
+                logger.LogInformation("Skipping step, as chocobo is unlocked");
+                return true;
+            }
+
+            if (SkipConditions.Contains(ESkipCondition.NotTargetable) &&
+                Step is { DataId: not null })
+            {
+                IGameObject? gameObject = gameFunctions.FindObjectByDataId(Step.DataId.Value);
+                if (gameObject == null)
+                {
+                    if ((Step.Position.GetValueOrDefault() - clientState.LocalPlayer!.Position).Length() < 100)
+                    {
+                        logger.LogInformation("Skipping step, object is not nearby (but we are)");
+                        return true;
+                    }
+                }
+                else if (!gameObject.IsTargetable)
+                {
+                    logger.LogInformation("Skipping step, object is not targetable");
+                    return true;
+                }
+            }
+
+            if (Step is
+                {
+                    DataId: not null,
+                    InteractionType: EInteractionType.AttuneAetheryte or EInteractionType.AttuneAethernetShard
+                } &&
+                gameFunctions.IsAetheryteUnlocked((EAetheryteLocation)Step.DataId.Value))
+            {
+                logger.LogInformation("Skipping step, as aetheryte/aethernet shard is unlocked");
+                return true;
+            }
+
+            if (Step is { DataId: not null, InteractionType: EInteractionType.AttuneAetherCurrent } &&
+                gameFunctions.IsAetherCurrentUnlocked(Step.DataId.Value))
+            {
+                logger.LogInformation("Skipping step, as current is unlocked");
+                return true;
+            }
+
+            QuestWork? questWork = gameFunctions.GetQuestEx(QuestId);
+            if (questWork != null && Step.MatchesQuestVariables(questWork.Value, true))
+            {
+                logger.LogInformation("Skipping step, as quest variables match");
+                return true;
+            }
+
+            return false;
+        }
+
+        public ETaskResult Update() => ETaskResult.SkipRemainingTasksForStep;
+
+        public override string ToString() => $"CheckSkip({string.Join(", ", SkipConditions)})";
+    }
+}
diff --git a/Questionable/Controller/Steps/Shared/StepDisabled.cs b/Questionable/Controller/Steps/Shared/StepDisabled.cs
new file mode 100644 (file)
index 0000000..16bb836
--- /dev/null
@@ -0,0 +1,34 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Shared;
+
+internal static class StepDisabled
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (!step.Disabled)
+                return null;
+
+            return serviceProvider.GetRequiredService<Task>();
+        }
+    }
+
+    internal sealed class Task(ILogger<Task> logger) : ITask
+    {
+        public bool Start() => true;
+
+        public ETaskResult Update()
+        {
+            logger.LogInformation("Skipping step, as it is disabled");
+            return ETaskResult.SkipRemainingTasksForStep;
+        }
+
+        public override string ToString() => "StepDisabled";
+    }
+}
diff --git a/Questionable/Controller/Steps/Shared/WaitAtEnd.cs b/Questionable/Controller/Steps/Shared/WaitAtEnd.cs
new file mode 100644 (file)
index 0000000..8ac7882
--- /dev/null
@@ -0,0 +1,274 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Numerics;
+using Dalamud.Game.ClientState.Conditions;
+using Dalamud.Plugin.Services;
+using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using Microsoft.Extensions.DependencyInjection;
+using Questionable.Controller.Steps.Common;
+using Questionable.Data;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Shared;
+
+internal static class WaitAtEnd
+{
+    internal sealed class Factory(IServiceProvider serviceProvider, IClientState clientState, ICondition condition,
+        TerritoryData territoryData)
+        : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.CompletionQuestVariablesFlags.Count == 6 && step.CompletionQuestVariablesFlags.Any(x => x is > 0))
+            {
+                var task = serviceProvider.GetRequiredService<WaitForCompletionFlags>()
+                    .With(quest, step);
+                var delay = serviceProvider.GetRequiredService<WaitDelay>();
+                return [task, delay, Next(quest, sequence)];
+            }
+
+            switch (step.InteractionType)
+            {
+                case EInteractionType.Combat:
+                    var notInCombat =
+                        new WaitConditionTask(() => !condition[ConditionFlag.InCombat], "Wait(not in combat)");
+                    return
+                    [
+                        serviceProvider.GetRequiredService<WaitDelay>(),
+                        notInCombat,
+                        serviceProvider.GetRequiredService<WaitDelay>(),
+                        Next(quest, sequence)
+                    ];
+
+                case EInteractionType.WaitForManualProgress:
+                case EInteractionType.ShouldBeAJump:
+                case EInteractionType.Instruction:
+                    return [serviceProvider.GetRequiredService<WaitNextStepOrSequence>()];
+
+                case EInteractionType.Duty:
+                case EInteractionType.SinglePlayerDuty:
+                    return [new EndAutomation()];
+
+                case EInteractionType.WalkTo:
+                case EInteractionType.Jump:
+                    // no need to wait if we're just moving around
+                    return [Next(quest, sequence)];
+
+                case EInteractionType.WaitForObjectAtPosition:
+                    ArgumentNullException.ThrowIfNull(step.DataId);
+                    ArgumentNullException.ThrowIfNull(step.Position);
+
+                    return
+                    [
+                        serviceProvider.GetRequiredService<WaitObjectAtPosition>()
+                            .With(step.DataId.Value, step.Position.Value, step.NpcWaitDistance ?? 0.05f),
+                        serviceProvider.GetRequiredService<WaitDelay>(),
+                        Next(quest, sequence)
+                    ];
+
+                case EInteractionType.Interact when step.TargetTerritoryId != null:
+                case EInteractionType.UseItem when step.TargetTerritoryId != null:
+                    ITask waitInteraction;
+                    if (step.TerritoryId != step.TargetTerritoryId)
+                    {
+                        // interaction moves to a different territory
+                        waitInteraction = new WaitConditionTask(
+                            () => clientState.TerritoryType == step.TargetTerritoryId,
+                            $"Wait(tp to territory: {territoryData.GetNameAndId(step.TargetTerritoryId.Value)})");
+                    }
+                    else
+                    {
+                        Vector3 lastPosition = step.Position ?? clientState.LocalPlayer?.Position ?? Vector3.Zero;
+                        waitInteraction = new WaitConditionTask(() =>
+                            {
+                                Vector3? currentPosition = clientState.LocalPlayer?.Position;
+                                if (currentPosition == null)
+                                    return false;
+
+                                // interaction moved to elsewhere in the zone
+                                // the 'closest' locations are probably
+                                //   - waking sands' solar
+                                //   - rising stones' solar + dawn's respite
+                                return (lastPosition - currentPosition.Value).Length() > 2;
+                            }, $"Wait(tp away from {lastPosition.ToString("G", CultureInfo.InvariantCulture)})");
+                    }
+
+                    return
+                    [
+                        waitInteraction,
+                        serviceProvider.GetRequiredService<WaitDelay>(),
+                        Next(quest, sequence)
+                    ];
+
+                case EInteractionType.AcceptQuest:
+                    return
+                    [
+                        serviceProvider.GetRequiredService<WaitQuestAccepted>().With(step.PickupQuestId ?? quest.QuestId),
+                        serviceProvider.GetRequiredService<WaitDelay>()
+                    ];
+
+                case EInteractionType.CompleteQuest:
+                    return
+                    [
+                        serviceProvider.GetRequiredService<WaitQuestCompleted>().With(step.TurnInQuestId ?? quest.QuestId),
+                        serviceProvider.GetRequiredService<WaitDelay>()
+                    ];
+
+                case EInteractionType.Interact:
+                default:
+                    return [serviceProvider.GetRequiredService<WaitDelay>(), Next(quest, sequence)];
+            }
+        }
+
+        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new InvalidOperationException();
+
+        private static NextStep Next(Quest quest, QuestSequence sequence)
+        {
+            return new NextStep(quest.QuestId, sequence.Sequence);
+        }
+    }
+
+    internal sealed class WaitDelay() : AbstractDelayedTask(TimeSpan.FromSeconds(1))
+    {
+        protected override bool StartInternal() => true;
+
+        public override string ToString() => $"Wait(seconds: {Delay.TotalSeconds})";
+    }
+
+    internal sealed class WaitNextStepOrSequence : ITask
+    {
+        public bool Start() => true;
+
+        public ETaskResult Update() => ETaskResult.StillRunning;
+
+        public override string ToString() => "Wait(next step or sequence)";
+    }
+
+    internal sealed class WaitForCompletionFlags(GameFunctions gameFunctions) : ITask
+    {
+        public Quest Quest { get; set; } = null!;
+        public QuestStep Step { get; set; } = null!;
+        public IList<short?> Flags { get; set; } = null!;
+
+        public ITask With(Quest quest, QuestStep step)
+        {
+            Quest = quest;
+            Step = step;
+            Flags = step.CompletionQuestVariablesFlags;
+            return this;
+        }
+
+        public bool Start() => true;
+
+        public ETaskResult Update()
+        {
+            QuestWork? questWork = gameFunctions.GetQuestEx(Quest.QuestId);
+            return questWork != null && Step.MatchesQuestVariables(questWork.Value, false)
+                ? ETaskResult.TaskComplete
+                : ETaskResult.StillRunning;
+        }
+
+        public override string ToString() =>
+            $"Wait(QW: {string.Join(", ", Flags.Select(x => x?.ToString(CultureInfo.InvariantCulture) ?? "-"))})";
+    }
+
+    internal sealed class WaitObjectAtPosition(GameFunctions gameFunctions) : ITask
+    {
+        public uint DataId { get; set; }
+        public Vector3 Destination { get; set; }
+        public float Distance { get; set; }
+
+        public ITask With(uint dataId, Vector3 destination, float distance)
+        {
+            DataId = dataId;
+            Destination = destination;
+            Distance = distance;
+            return this;
+        }
+
+        public bool Start() => true;
+
+        public ETaskResult Update() =>
+            gameFunctions.IsObjectAtPosition(DataId, Destination, Distance)
+                ? ETaskResult.TaskComplete
+                : ETaskResult.StillRunning;
+
+        public override string ToString() =>
+            $"WaitObj({DataId} at {Destination.ToString("G", CultureInfo.InvariantCulture)})";
+    }
+
+    internal sealed class WaitQuestAccepted : ITask
+    {
+        public ushort QuestId { get; set; }
+
+        public ITask With(ushort questId)
+        {
+            QuestId = questId;
+            return this;
+        }
+
+        public bool Start() => true;
+
+        public ETaskResult Update()
+        {
+            unsafe
+            {
+                var questManager = QuestManager.Instance();
+                return questManager != null && questManager->IsQuestAccepted(QuestId)
+                    ? ETaskResult.TaskComplete
+                    : ETaskResult.StillRunning;
+            }
+        }
+
+        public override string ToString() => $"WaitQuestAccepted({QuestId})";
+    }
+
+    internal sealed class WaitQuestCompleted : ITask
+    {
+        public ushort QuestId { get; set; }
+
+        public ITask With(ushort questId)
+        {
+            QuestId = questId;
+            return this;
+        }
+
+        public bool Start() => true;
+
+        public ETaskResult Update()
+        {
+            return QuestManager.IsQuestComplete(QuestId) ? ETaskResult.TaskComplete : ETaskResult.StillRunning;
+        }
+
+        public override string ToString() => $"WaitQuestComplete({QuestId})";
+    }
+
+    internal sealed class NextStep(ushort questId, int sequence) : ILastTask
+    {
+        public ushort QuestId { get; } = questId;
+        public int Sequence { get; } = sequence;
+
+        public bool Start() => true;
+
+        public ETaskResult Update() => ETaskResult.NextStep;
+
+        public override string ToString() => "NextStep";
+    }
+
+    internal sealed class EndAutomation : ILastTask
+    {
+        public ushort QuestId => throw new InvalidOperationException();
+        public int Sequence => throw new InvalidOperationException();
+
+        public bool Start() => true;
+
+        public ETaskResult Update() => ETaskResult.End;
+
+        public override string ToString() => "EndAutomation";
+    }
+}
diff --git a/Questionable/Controller/Steps/Shared/WaitAtStart.cs b/Questionable/Controller/Steps/Shared/WaitAtStart.cs
new file mode 100644 (file)
index 0000000..4a1f411
--- /dev/null
@@ -0,0 +1,35 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Questionable.Controller.Steps.Common;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.Shared;
+
+internal static class WaitAtStart
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.DelaySecondsAtStart == null)
+                return null;
+
+            return serviceProvider.GetRequiredService<WaitDelay>()
+                .With(TimeSpan.FromSeconds(step.DelaySecondsAtStart.Value));
+        }
+    }
+
+    internal sealed class WaitDelay : AbstractDelayedTask
+    {
+        public ITask With(TimeSpan delay)
+        {
+            Delay = delay;
+            return this;
+        }
+
+        protected override bool StartInternal() => true;
+
+        public override string ToString() => $"Wait[S](seconds: {Delay.TotalSeconds})";
+    }
+}
index 6975eaeb7126fd1b871fd349a1d171274f45ab31..356c096297bb3659bd017bcecda4362f5fb19311 100644 (file)
@@ -2,7 +2,7 @@
   "Name": "Questionable",
   "Author": "Liza Carvelli",
   "Punchline": "A tiny quest helper plugin.",
-  "Description": "A tiny quest helper plugin.",
+  "Description": "A tiny little quest helper plugin, which does quests for you automatically where possible. Uses navmesh to automatically walk to all quest waypoints, and tries to automatically complete all steps along the way (excluding dungeons, single player duties and combat).\n\nNot all quests are supported, check the discord or git repository for an up-to-date list.\n\nRequired Plugins: vnavmesh, TextAdvance, Lifestream",
   "Tags": [
     "quests",
     "msq"
index 2fe468df8a5fbbcc5a43441bc8b78c417b9919bc..df02a58228019252c6afca417f9532766df349d3 100644 (file)
@@ -11,13 +11,13 @@ using Microsoft.Extensions.Logging;
 using Questionable.Controller;
 using Questionable.Controller.NavigationOverrides;
 using Questionable.Controller.Steps;
-using Questionable.Controller.Steps.BaseFactory;
-using Questionable.Controller.Steps.BaseTasks;
-using Questionable.Controller.Steps.InteractionFactory;
+using Questionable.Controller.Steps.Shared;
+using Questionable.Controller.Steps.Common;
+using Questionable.Controller.Steps.Interactions;
 using Questionable.Data;
 using Questionable.External;
 using Questionable.Windows;
-using Action = Questionable.Controller.Steps.InteractionFactory.Action;
+using Action = Questionable.Controller.Steps.Interactions.Action;
 
 namespace Questionable;
 
index 902d247a332e2803a7b131908c051b51532b1a0c..e778372d1a88adf4b055d064225efdd8e7a7ed4c 100644 (file)
@@ -41,7 +41,11 @@ internal sealed class DebugOverlay : Window
 
     public ushort? HighlightedQuest { get; set; }
 
-    public override bool DrawConditions() => _configuration.Advanced.DebugOverlay;
+    public override bool DrawConditions()
+    {
+        return _configuration.Advanced.DebugOverlay && _clientState is
+            { IsLoggedIn: true, LocalPlayer: not null, IsPvPExcludingDen: false };
+    }
 
     public override void PreDraw()
     {
index 0d868109db2ae42fbad9e9a3eb47dda6b8523846..0f72fcabd9265333168ca8874c7707fbf96193b3 100644 (file)
@@ -19,7 +19,7 @@ using ImGuiNET;
 using LLib.ImGui;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller;
-using Questionable.Controller.Steps.BaseFactory;
+using Questionable.Controller.Steps.Shared;
 using Questionable.Data;
 using Questionable.External;
 using Questionable.Model;
@@ -96,7 +96,7 @@ internal sealed class QuestWindow : LWindow, IPersistableWindowConfig
 
     public override bool DrawConditions()
     {
-        if (!_clientState.IsLoggedIn || _clientState.LocalPlayer == null)
+        if (!_clientState.IsLoggedIn || _clientState.LocalPlayer == null || _clientState.IsPvPExcludingDen)
             return false;
 
         if (_configuration.General.HideInAllInstances && _territoryData.IsDutyInstance(_clientState.TerritoryType))