Rewrite logic, all quest steps can be executed automatically now
authorLiza Carvelli <liza@carvel.li>
Sun, 9 Jun 2024 14:30:53 +0000 (16:30 +0200)
committerLiza Carvelli <liza@carvel.li>
Sun, 9 Jun 2024 14:31:37 +0000 (16:31 +0200)
40 files changed:
QuestPaths/Endwalker/MSQ/C-MareLamentorum/4405_Back to Old Tricks.json
QuestPaths/Endwalker/MSQ/C-MareLamentorum/4406_Settiing Things Straight.json [deleted file]
QuestPaths/Endwalker/MSQ/C-MareLamentorum/4406_Setting Things Straight.json [new file with mode: 0644]
Questionable/Controller/GameUiController.cs
Questionable/Controller/MovementController.cs
Questionable/Controller/QuestController.cs
Questionable/Controller/Steps/BaseFactory/AethernetShortcut.cs [new file with mode: 0644]
Questionable/Controller/Steps/BaseFactory/AetheryteShortcut.cs [new file with mode: 0644]
Questionable/Controller/Steps/BaseFactory/Move.cs [new file with mode: 0644]
Questionable/Controller/Steps/BaseFactory/SkipCondition.cs [new file with mode: 0644]
Questionable/Controller/Steps/BaseFactory/StepDisabled.cs [new file with mode: 0644]
Questionable/Controller/Steps/BaseFactory/WaitAtEnd.cs [new file with mode: 0644]
Questionable/Controller/Steps/BaseFactory/ZoneChange.cs [new file with mode: 0644]
Questionable/Controller/Steps/BaseTasks/AbstractDelayedTask.cs [new file with mode: 0644]
Questionable/Controller/Steps/BaseTasks/MountTask.cs [new file with mode: 0644]
Questionable/Controller/Steps/BaseTasks/UnmountTask.cs [new file with mode: 0644]
Questionable/Controller/Steps/BaseTasks/WaitConditionTask.cs [new file with mode: 0644]
Questionable/Controller/Steps/ETaskResult.cs [new file with mode: 0644]
Questionable/Controller/Steps/ILastTask.cs [new file with mode: 0644]
Questionable/Controller/Steps/ITask.cs [new file with mode: 0644]
Questionable/Controller/Steps/ITaskFactory.cs [new file with mode: 0644]
Questionable/Controller/Steps/InteractionFactory/AetherCurrent.cs [new file with mode: 0644]
Questionable/Controller/Steps/InteractionFactory/AethernetShard.cs [new file with mode: 0644]
Questionable/Controller/Steps/InteractionFactory/Aetheryte.cs [new file with mode: 0644]
Questionable/Controller/Steps/InteractionFactory/Combat.cs [new file with mode: 0644]
Questionable/Controller/Steps/InteractionFactory/Duty.cs [new file with mode: 0644]
Questionable/Controller/Steps/InteractionFactory/Emote.cs [new file with mode: 0644]
Questionable/Controller/Steps/InteractionFactory/Interact.cs [new file with mode: 0644]
Questionable/Controller/Steps/InteractionFactory/Jump.cs [new file with mode: 0644]
Questionable/Controller/Steps/InteractionFactory/Say.cs [new file with mode: 0644]
Questionable/Controller/Steps/InteractionFactory/UseItem.cs [new file with mode: 0644]
Questionable/Controller/Steps/TaskException.cs [new file with mode: 0644]
Questionable/DalamudInitializer.cs
Questionable/GameFunctions.cs
Questionable/GlobalSuppressions.cs [deleted file]
Questionable/Questionable.csproj
Questionable/QuestionablePlugin.cs
Questionable/ServiceCollectionExtensions.cs [new file with mode: 0644]
Questionable/Windows/DebugWindow.cs
Questionable/packages.lock.json

index 4e91502c7bbd2e7ce8538ec6c4ebee71b2e56602..419959b53e0d2ca0f70b387565ea1e931440120d 100644 (file)
         }
       ]
     },
+    {
+      "Sequence": 2,
+      "Steps": [
+        {
+          "DataId": 2012185,
+          "Position": {
+            "X": -5.416992,
+            "Y": -49.05786,
+            "Z": -269.24548
+          },
+          "TerritoryId": 959,
+          "InteractionType": "WaitForManualProgress",
+          "Comment": "Follow Urianger"
+        }
+      ]
+    },
     {
       "Sequence": 3,
       "Steps": [
@@ -45,7 +61,7 @@
             "Z": -269.24548
           },
           "TerritoryId": 959,
-          "InteractionType": "SinglePlayerDuty",
+          "InteractionType": "WaitForManualProgress",
           "Comment": "Follow Urianger"
         }
       ]
diff --git a/QuestPaths/Endwalker/MSQ/C-MareLamentorum/4406_Settiing Things Straight.json b/QuestPaths/Endwalker/MSQ/C-MareLamentorum/4406_Settiing Things Straight.json
deleted file mode 100644 (file)
index f84dcfd..0000000
+++ /dev/null
@@ -1,215 +0,0 @@
-{
-  "$schema": "https://carvel.li/questionable/quest-1.0",
-  "Author": "liza",
-  "QuestSequence": [
-    {
-      "Sequence": 0,
-      "Steps": [
-        {
-          "DataId": 1038935,
-          "Position": {
-            "X": 68.5282,
-            "Y": 75.72459,
-            "Z": -23.51416
-          },
-          "StopDistance": 5,
-          "TerritoryId": 959,
-          "InteractionType": "Interact"
-        }
-      ]
-    },
-    {
-      "Sequence": 1,
-      "Steps": [
-        {
-          "Position": {
-            "X": -126.76068,
-            "Y": 61.04055,
-            "Z": -76.382324
-          },
-          "TerritoryId": 959,
-          "InteractionType": "WalkTo"
-        },
-        {
-          "DataId": 2012014,
-          "Position": {
-            "X": -128.008,
-            "Y": 66.33093,
-            "Z": -68.2536
-          },
-          "StopDistance": 5,
-          "TerritoryId": 959,
-          "InteractionType": "AttuneAetherCurrent",
-          "AetherCurrentId": 2818363
-        },
-        {
-          "Position": {
-            "X": -116.83438,
-            "Y": 63.151585,
-            "Z": -71.81973
-          },
-          "TerritoryId": 959,
-          "InteractionType": "WalkTo",
-          "Mount": true
-        },
-        {
-          "DataId": 2012010,
-          "Position": {
-            "X": 42.58789,
-            "Y": 124.01001,
-            "Z": -167.04059
-          },
-          "TerritoryId": 959,
-          "InteractionType": "AttuneAetherCurrent",
-          "AetherCurrentId": 2818359
-        },
-        {
-          "DataId": 1038936,
-          "Position": {
-            "X": 191.48547,
-            "Y": 93.90228,
-            "Z": -54.306885
-          },
-          "TerritoryId": 959,
-          "InteractionType": "Interact"
-        }
-      ]
-    },
-    {
-      "Sequence": 2,
-      "Steps": [
-        {
-          "DataId": 2012235,
-          "Position": {
-            "X": 183.30652,
-            "Y": 87.20532,
-            "Z": -30.47229
-          },
-          "TerritoryId": 959,
-          "InteractionType": "Combat",
-          "EnemySpawnType": "AfterInteraction",
-          "KillEnemyDataIds": [
-            13998,
-            14093,
-            13998
-          ]
-        }
-      ]
-    },
-    {
-      "Sequence": 3,
-      "Steps": [
-        {
-          "DataId": 2012433,
-          "Position": {
-            "X": 183.30652,
-            "Y": 87.17468,
-            "Z": -30.380737
-          },
-          "TerritoryId": 959,
-          "InteractionType": "Interact"
-        }
-      ]
-    },
-    {
-      "Sequence": 4,
-      "Steps": [
-        {
-          "DataId": 1038936,
-          "Position": {
-            "X": 191.48547,
-            "Y": 93.90228,
-            "Z": -54.306885
-          },
-          "TerritoryId": 959,
-          "InteractionType": "Interact"
-        }
-      ]
-    },
-    {
-      "Sequence": 5,
-      "Steps": [
-        {
-          "Position": {
-            "X": 304.50522,
-            "Y": -144,
-            "Z": -558.8681
-          },
-          "TerritoryId": 959,
-          "InteractionType": "Jump",
-          "AetheryteShortcut": "Mare Lamentorum - Bestways Burrow",
-          "JumpDestination": {
-            "Position": {
-              "X": 307.72073,
-              "Y": -143.15913,
-              "Z": -563.4788
-            },
-            "StopDistance": 5
-          }
-        },
-        {
-          "DataId": 2012012,
-          "Position": {
-            "X": 316.39575,
-            "Y": -154.98596,
-            "Z": -595.5444
-          },
-          "StopDistance": 3,
-          "TerritoryId": 959,
-          "InteractionType": "AttuneAetherCurrent",
-          "AetherCurrentId": 2818361
-        },
-        {
-          "Position": {
-            "X": 351.1467,
-            "Y": -167.87698,
-            "Z": -605.0467
-          },
-          "TerritoryId": 959,
-          "InteractionType": "WalkTo"
-        },
-        {
-          "DataId": 1038937,
-          "Position": {
-            "X": 381.7959,
-            "Y": -168.00002,
-            "Z": -594.62885
-          },
-          "TerritoryId": 959,
-          "InteractionType": "Interact"
-        }
-      ]
-    },
-    {
-      "Sequence": 6,
-      "Steps": [
-        {
-          "DataId": 2012337,
-          "Position": {
-            "X": 466.8496,
-            "Y": -168.01715,
-            "Z": -654.13904
-          },
-          "TerritoryId": 959,
-          "InteractionType": "SinglePlayerDuty",
-          "Comment": "Find \"Runninway?\""
-        }
-      ]
-    },
-    {
-      "Sequence": 255,
-      "Steps": [
-        {
-          "DataId": 1038944,
-          "Position": {
-            "X": 361.65405,
-            "Y": -168.00002,
-            "Z": -628.19867
-          },
-          "TerritoryId": 959,
-          "InteractionType": "Interact"
-        }
-      ]
-    }
-  ]
-}
diff --git a/QuestPaths/Endwalker/MSQ/C-MareLamentorum/4406_Setting Things Straight.json b/QuestPaths/Endwalker/MSQ/C-MareLamentorum/4406_Setting Things Straight.json
new file mode 100644 (file)
index 0000000..f84dcfd
--- /dev/null
@@ -0,0 +1,215 @@
+{
+  "$schema": "https://carvel.li/questionable/quest-1.0",
+  "Author": "liza",
+  "QuestSequence": [
+    {
+      "Sequence": 0,
+      "Steps": [
+        {
+          "DataId": 1038935,
+          "Position": {
+            "X": 68.5282,
+            "Y": 75.72459,
+            "Z": -23.51416
+          },
+          "StopDistance": 5,
+          "TerritoryId": 959,
+          "InteractionType": "Interact"
+        }
+      ]
+    },
+    {
+      "Sequence": 1,
+      "Steps": [
+        {
+          "Position": {
+            "X": -126.76068,
+            "Y": 61.04055,
+            "Z": -76.382324
+          },
+          "TerritoryId": 959,
+          "InteractionType": "WalkTo"
+        },
+        {
+          "DataId": 2012014,
+          "Position": {
+            "X": -128.008,
+            "Y": 66.33093,
+            "Z": -68.2536
+          },
+          "StopDistance": 5,
+          "TerritoryId": 959,
+          "InteractionType": "AttuneAetherCurrent",
+          "AetherCurrentId": 2818363
+        },
+        {
+          "Position": {
+            "X": -116.83438,
+            "Y": 63.151585,
+            "Z": -71.81973
+          },
+          "TerritoryId": 959,
+          "InteractionType": "WalkTo",
+          "Mount": true
+        },
+        {
+          "DataId": 2012010,
+          "Position": {
+            "X": 42.58789,
+            "Y": 124.01001,
+            "Z": -167.04059
+          },
+          "TerritoryId": 959,
+          "InteractionType": "AttuneAetherCurrent",
+          "AetherCurrentId": 2818359
+        },
+        {
+          "DataId": 1038936,
+          "Position": {
+            "X": 191.48547,
+            "Y": 93.90228,
+            "Z": -54.306885
+          },
+          "TerritoryId": 959,
+          "InteractionType": "Interact"
+        }
+      ]
+    },
+    {
+      "Sequence": 2,
+      "Steps": [
+        {
+          "DataId": 2012235,
+          "Position": {
+            "X": 183.30652,
+            "Y": 87.20532,
+            "Z": -30.47229
+          },
+          "TerritoryId": 959,
+          "InteractionType": "Combat",
+          "EnemySpawnType": "AfterInteraction",
+          "KillEnemyDataIds": [
+            13998,
+            14093,
+            13998
+          ]
+        }
+      ]
+    },
+    {
+      "Sequence": 3,
+      "Steps": [
+        {
+          "DataId": 2012433,
+          "Position": {
+            "X": 183.30652,
+            "Y": 87.17468,
+            "Z": -30.380737
+          },
+          "TerritoryId": 959,
+          "InteractionType": "Interact"
+        }
+      ]
+    },
+    {
+      "Sequence": 4,
+      "Steps": [
+        {
+          "DataId": 1038936,
+          "Position": {
+            "X": 191.48547,
+            "Y": 93.90228,
+            "Z": -54.306885
+          },
+          "TerritoryId": 959,
+          "InteractionType": "Interact"
+        }
+      ]
+    },
+    {
+      "Sequence": 5,
+      "Steps": [
+        {
+          "Position": {
+            "X": 304.50522,
+            "Y": -144,
+            "Z": -558.8681
+          },
+          "TerritoryId": 959,
+          "InteractionType": "Jump",
+          "AetheryteShortcut": "Mare Lamentorum - Bestways Burrow",
+          "JumpDestination": {
+            "Position": {
+              "X": 307.72073,
+              "Y": -143.15913,
+              "Z": -563.4788
+            },
+            "StopDistance": 5
+          }
+        },
+        {
+          "DataId": 2012012,
+          "Position": {
+            "X": 316.39575,
+            "Y": -154.98596,
+            "Z": -595.5444
+          },
+          "StopDistance": 3,
+          "TerritoryId": 959,
+          "InteractionType": "AttuneAetherCurrent",
+          "AetherCurrentId": 2818361
+        },
+        {
+          "Position": {
+            "X": 351.1467,
+            "Y": -167.87698,
+            "Z": -605.0467
+          },
+          "TerritoryId": 959,
+          "InteractionType": "WalkTo"
+        },
+        {
+          "DataId": 1038937,
+          "Position": {
+            "X": 381.7959,
+            "Y": -168.00002,
+            "Z": -594.62885
+          },
+          "TerritoryId": 959,
+          "InteractionType": "Interact"
+        }
+      ]
+    },
+    {
+      "Sequence": 6,
+      "Steps": [
+        {
+          "DataId": 2012337,
+          "Position": {
+            "X": 466.8496,
+            "Y": -168.01715,
+            "Z": -654.13904
+          },
+          "TerritoryId": 959,
+          "InteractionType": "SinglePlayerDuty",
+          "Comment": "Find \"Runninway?\""
+        }
+      ]
+    },
+    {
+      "Sequence": 255,
+      "Steps": [
+        {
+          "DataId": 1038944,
+          "Position": {
+            "X": 361.65405,
+            "Y": -168.00002,
+            "Z": -628.19867
+          },
+          "TerritoryId": 959,
+          "InteractionType": "Interact"
+        }
+      ]
+    }
+  ]
+}
index eb74e4f23cac663bf7805fe1dcf4600e859dbdaa..3e8293341d5a98e5bf87b0bf788f9e431f2a5a21 100644 (file)
@@ -362,8 +362,8 @@ internal sealed class GameUiController : IDisposable
 
             _logger.LogInformation("Using warp {Id}, {Prompt}", entry.RowId, excelPrompt);
             addonSelectYesno->AtkUnitBase.FireCallbackInt(0);
-            if (increaseStepCount)
-                _questController.IncreaseStepCount();
+            //if (increaseStepCount)
+                //_questController.IncreaseStepCount();
             return;
         }
     }
index bacea45cc60bd46deeda8f95654b913396689e7b..a3d978651d593024523212316eaafd4be3444adb 100644 (file)
@@ -45,6 +45,7 @@ internal sealed class MovementController : IDisposable
     public bool IsPathRunning => _navmeshIpc.IsPathRunning;
     public bool IsPathfinding => _pathfindTask is { IsCompleted: false };
     public DestinationData? Destination { get; private set; }
+    public DateTime MovementStartedAt { get; private set; } = DateTime.MaxValue;
 
     public void Update()
     {
@@ -53,7 +54,14 @@ internal sealed class MovementController : IDisposable
             if (_pathfindTask.IsCompletedSuccessfully)
             {
                 _logger.LogInformation("Pathfinding complete, route: [{Route}]",
-                    string.Join(" → ", _pathfindTask.Result.Select(x => x.ToString("G", CultureInfo.InvariantCulture))));
+                    string.Join(" → ",
+                        _pathfindTask.Result.Select(x => x.ToString("G", CultureInfo.InvariantCulture))));
+
+                if (_pathfindTask.Result.Count == 0)
+                {
+                    ResetPathfinding();
+                    throw new PathfindingFailedException();
+                }
 
                 var navPoints = _pathfindTask.Result.Skip(1).ToList();
                 Vector3 start = _clientState.LocalPlayer?.Position ?? navPoints[0];
@@ -90,12 +98,15 @@ internal sealed class MovementController : IDisposable
                 }
 
                 _navmeshIpc.MoveTo(navPoints, Destination.IsFlying);
+                MovementStartedAt = DateTime.Now;
+
                 ResetPathfinding();
             }
             else if (_pathfindTask.IsCompleted)
             {
                 _logger.LogWarning("Unable to complete pathfinding task");
                 ResetPathfinding();
+                throw new PathfindingFailedException();
             }
         }
 
@@ -156,7 +167,8 @@ internal sealed class MovementController : IDisposable
         return pointOnFloor != null && Math.Abs(pointOnFloor.Value.Y - p.Y) > 0.5f;
     }
 
-    private void PrepareNavigation(EMovementType type, uint? dataId, Vector3 to, bool fly, bool sprint, float? stopDistance)
+    private void PrepareNavigation(EMovementType type, uint? dataId, Vector3 to, bool fly, bool sprint,
+        float? stopDistance)
     {
         ResetPathfinding();
 
@@ -164,9 +176,11 @@ internal sealed class MovementController : IDisposable
             _gameFunctions.ExecuteCommand("/automove off");
 
         Destination = new DestinationData(dataId, to, stopDistance ?? (DefaultStopDistance - 0.2f), fly, sprint);
+        MovementStartedAt = DateTime.MaxValue;
     }
 
-    public void NavigateTo(EMovementType type, uint? dataId, Vector3 to, bool fly, bool sprint, float? stopDistance = null)
+    public void NavigateTo(EMovementType type, uint? dataId, Vector3 to, bool fly, bool sprint,
+        float? stopDistance = null)
     {
         fly |= _condition[ConditionFlag.Diving];
         PrepareNavigation(type, dataId, to, fly, sprint, stopDistance);
@@ -178,13 +192,15 @@ internal sealed class MovementController : IDisposable
             _navmeshIpc.Pathfind(_clientState.LocalPlayer!.Position, to, fly, _cancellationTokenSource.Token);
     }
 
-    public void NavigateTo(EMovementType type, uint? dataId, List<Vector3> to, bool fly, bool sprint, float? stopDistance)
+    public void NavigateTo(EMovementType type, uint? dataId, List<Vector3> to, bool fly, bool sprint,
+        float? stopDistance)
     {
         fly |= _condition[ConditionFlag.Diving];
         PrepareNavigation(type, dataId, to.Last(), fly, sprint, stopDistance);
 
         _logger.LogInformation("Moving to {Destination}", Destination);
         _navmeshIpc.MoveTo(to, fly);
+        MovementStartedAt = DateTime.Now;
     }
 
     public void ResetPathfinding()
@@ -219,5 +235,27 @@ internal sealed class MovementController : IDisposable
         Stop();
     }
 
-    public sealed record DestinationData(uint? DataId, Vector3 Position, float StopDistance, bool IsFlying, bool CanSprint);
+    public sealed record DestinationData(
+        uint? DataId,
+        Vector3 Position,
+        float StopDistance,
+        bool IsFlying,
+        bool CanSprint);
+
+    public sealed class PathfindingFailedException : Exception
+    {
+        public PathfindingFailedException()
+        {
+        }
+
+        public PathfindingFailedException(string message)
+            : base(message)
+        {
+        }
+
+        public PathfindingFailedException(string message, Exception innerException)
+            : base(message, innerException)
+        {
+        }
+    }
 }
index 34f7d72272079442516bb72f554eadd4833b1773..14b076ce10902752e0c54bc8dbbb748c69005689 100644 (file)
@@ -1,11 +1,17 @@
 using System;
+using System.Collections.Generic;
+using System.Linq;
 using System.Numerics;
+using System.Threading;
+using System.Threading.Tasks;
 using Dalamud.Game.ClientState.Conditions;
+using Dalamud.Game.ClientState.Keys;
 using Dalamud.Game.ClientState.Objects.Types;
 using Dalamud.Plugin.Services;
 using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
 using FFXIVClientStructs.FFXIV.Client.Game;
 using Microsoft.Extensions.Logging;
+using Questionable.Controller.Steps;
 using Questionable.Data;
 using Questionable.External;
 using Questionable.Model;
@@ -20,30 +26,30 @@ internal sealed class QuestController
     private readonly GameFunctions _gameFunctions;
     private readonly MovementController _movementController;
     private readonly ILogger<QuestController> _logger;
-    private readonly ICondition _condition;
-    private readonly IChatGui _chatGui;
-    private readonly IFramework _framework;
-    private readonly AetheryteData _aetheryteData;
-    private readonly LifestreamIpc _lifestreamIpc;
-    private readonly TerritoryData _territoryData;
     private readonly QuestRegistry _questRegistry;
-
-    public QuestController(IClientState clientState, GameFunctions gameFunctions, MovementController movementController,
-        ILogger<QuestController> logger, ICondition condition, IChatGui chatGui, IFramework framework,
-        AetheryteData aetheryteData, LifestreamIpc lifestreamIpc, TerritoryData territoryData,
-        QuestRegistry questRegistry)
+    private readonly IKeyState _keyState;
+    private readonly IReadOnlyList<ITaskFactory> _taskFactories;
+
+    private readonly Queue<ITask> _taskQueue = new();
+    private ITask? _currentTask;
+    private bool _automatic;
+
+    public QuestController(
+        IClientState clientState,
+        GameFunctions gameFunctions,
+        MovementController movementController,
+        ILogger<QuestController> logger,
+        QuestRegistry questRegistry,
+        IKeyState keyState,
+        IEnumerable<ITaskFactory> taskFactories)
     {
         _clientState = clientState;
         _gameFunctions = gameFunctions;
         _movementController = movementController;
         _logger = logger;
-        _condition = condition;
-        _chatGui = chatGui;
-        _framework = framework;
-        _aetheryteData = aetheryteData;
-        _lifestreamIpc = lifestreamIpc;
-        _territoryData = territoryData;
         _questRegistry = questRegistry;
+        _keyState = keyState;
+        _taskFactories = taskFactories.ToList().AsReadOnly();
     }
 
 
@@ -60,6 +66,22 @@ internal sealed class QuestController
     }
 
     public void Update()
+    {
+        UpdateCurrentQuest();
+
+        if (_keyState[VirtualKey.ESCAPE])
+        {
+            Stop();
+            _movementController.Stop();
+        }
+
+        if (CurrentQuest != null && CurrentQuest.Quest.Data.TerritoryBlacklist.Contains(_clientState.TerritoryType))
+            return;
+
+        UpdateCurrentTask();
+    }
+
+    private void UpdateCurrentQuest()
     {
         DebugState = null;
 
@@ -67,28 +89,38 @@ internal sealed class QuestController
         if (currentQuestId == 0)
         {
             if (CurrentQuest != null)
+            {
+                _logger.LogInformation("No current quest, resetting data");
                 CurrentQuest = null;
+                Stop();
+            }
         }
         else if (CurrentQuest == null || CurrentQuest.Quest.QuestId != currentQuestId)
         {
             if (_questRegistry.TryGetQuest(currentQuestId, out var quest))
+            {
+                _logger.LogInformation("New quest: {QuestName}", quest.Name);
                 CurrentQuest = new QuestProgress(quest, currentSequence, 0);
+            }
             else if (CurrentQuest != null)
+            {
+                _logger.LogInformation("No active quest anymore? Not sure what happened...");
                 CurrentQuest = null;
+            }
+
+            Stop();
+            return;
         }
 
         if (CurrentQuest == null)
         {
             DebugState = "No quest active";
             Comment = null;
+            Stop();
             return;
         }
 
-        if (_condition[ConditionFlag.Occupied] || _condition[ConditionFlag.Occupied30] ||
-            _condition[ConditionFlag.Occupied33] || _condition[ConditionFlag.Occupied38] ||
-            _condition[ConditionFlag.Occupied39] || _condition[ConditionFlag.OccupiedInEvent] ||
-            _condition[ConditionFlag.OccupiedInQuestEvent] || _condition[ConditionFlag.OccupiedInCutSceneEvent] ||
-            _condition[ConditionFlag.Casting] || _condition[ConditionFlag.Unknown57])
+        if (_gameFunctions.IsOccupied())
         {
             DebugState = "Occupied";
             return;
@@ -106,14 +138,22 @@ internal sealed class QuestController
         }
 
         if (CurrentQuest.Sequence != currentSequence)
+        {
             CurrentQuest = CurrentQuest with { Sequence = currentSequence, Step = 0 };
 
+            bool automatic = _automatic;
+            Stop();
+            if (automatic)
+                ExecuteNextStep(true);
+        }
+
         var q = CurrentQuest.Quest;
         var sequence = q.FindSequence(CurrentQuest.Sequence);
         if (sequence == null)
         {
             DebugState = "Sequence not found";
             Comment = null;
+            Stop();
             return;
         }
 
@@ -121,6 +161,7 @@ internal sealed class QuestController
         {
             DebugState = "Step completed";
             Comment = null;
+            Stop();
             return;
         }
 
@@ -128,6 +169,7 @@ internal sealed class QuestController
         {
             DebugState = "Step not found";
             Comment = null;
+            Stop();
             return;
         }
 
@@ -152,7 +194,7 @@ internal sealed class QuestController
         return (seq, seq.Steps[CurrentQuest.Step]);
     }
 
-    public void IncreaseStepCount()
+    public void IncreaseStepCount(bool shouldContinue = false)
     {
         (QuestSequence? seq, QuestStep? step) = GetNextStep();
         if (CurrentQuest == null || seq == null || step == null)
@@ -161,6 +203,7 @@ internal sealed class QuestController
             return;
         }
 
+        _logger.LogInformation("Increasing step count from {CurrentValue}", CurrentQuest.Step);
         if (CurrentQuest.Step + 1 < seq.Steps.Count)
         {
             CurrentQuest = CurrentQuest with
@@ -177,6 +220,10 @@ internal sealed class QuestController
                 StepProgress = new()
             };
         }
+
+
+        if (shouldContinue && _automatic)
+            ExecuteNextStep(true);
     }
 
     public void IncreaseDialogueChoicesSelected()
@@ -196,453 +243,155 @@ internal sealed class QuestController
             }
         };
 
+        /* TODO Is this required?
         if (CurrentQuest.StepProgress.DialogueChoicesSelected >= step.DialogueChoices.Count)
             IncreaseStepCount();
+            */
     }
 
-    public unsafe void ExecuteNextStep()
+    public void Stop()
     {
-        (QuestSequence? seq, QuestStep? step) = GetNextStep();
-        if (CurrentQuest == null || seq == null || step == null)
-        {
-            _logger.LogWarning("Could not retrieve next quest step, not doing anything");
-            return;
-        }
-
-        if (step.Disabled)
-        {
-            _logger.LogInformation("Skipping step, as it is disabled");
-            IncreaseStepCount();
-            return;
-        }
-
-        if (!CurrentQuest.StepProgress.AetheryteShortcutUsed && step.AetheryteShortcut != null)
-        {
-            bool skipTeleport = false;
-            ushort territoryType = _clientState.TerritoryType;
-            if (step.TerritoryId == territoryType)
-            {
-                Vector3 pos = _clientState.LocalPlayer!.Position;
-                if (_aetheryteData.CalculateDistance(pos, territoryType, step.AetheryteShortcut.Value) < 11 ||
-                    (step.AethernetShortcut != null &&
-                     (_aetheryteData.CalculateDistance(pos, territoryType, step.AethernetShortcut.From) < 20 ||
-                      _aetheryteData.CalculateDistance(pos, territoryType, step.AethernetShortcut.To) < 20)))
-                {
-                    _logger.LogInformation("Skipping aetheryte teleport");
-                    skipTeleport = true;
-                }
-            }
-
-            if (skipTeleport)
-            {
-                _logger.LogInformation("Marking aetheryte shortcut as used");
-                CurrentQuest = CurrentQuest with
-                {
-                    StepProgress = CurrentQuest.StepProgress with { AetheryteShortcutUsed = true }
-                };
-            }
-            else
-            {
-                if (!_gameFunctions.IsAetheryteUnlocked(step.AetheryteShortcut.Value))
-                {
-                    _logger.LogError("Aetheryte {Aetheryte} is not unlocked.", step.AetheryteShortcut.Value);
-                    _chatGui.Print($"[Questionable] Aetheryte {step.AetheryteShortcut.Value} is not unlocked.");
-                }
-                else if (_gameFunctions.TeleportAetheryte(step.AetheryteShortcut.Value))
-                {
-                    _logger.LogInformation("Travelling via aetheryte...");
-                    CurrentQuest = CurrentQuest with
-                    {
-                        StepProgress = CurrentQuest.StepProgress with { AetheryteShortcutUsed = true }
-                    };
-                }
-                else
-                {
-                    _logger.LogWarning("Unable to teleport to aetheryte");
-                    _chatGui.Print("[Questionable] Unable to teleport to aetheryte.");
-                }
-
-                return;
-            }
-        }
-
-        if (!step.SkipIf.Contains(ESkipCondition.Never))
-        {
-            _logger.LogInformation("Checking skip conditions; {ConfiguredConditions}", string.Join(",", step.SkipIf));
-
-            if (step.SkipIf.Contains(ESkipCondition.FlyingUnlocked) &&
-                _gameFunctions.IsFlyingUnlocked(step.TerritoryId))
-            {
-                _logger.LogInformation("Skipping step, as flying is unlocked");
-                IncreaseStepCount();
-                return;
-            }
-
-            if (step.SkipIf.Contains(ESkipCondition.FlyingLocked) &&
-                !_gameFunctions.IsFlyingUnlocked(step.TerritoryId))
-            {
-                _logger.LogInformation("Skipping step, as flying is locked");
-                IncreaseStepCount();
-                return;
-            }
+        _currentTask = null;
 
-            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");
-                IncreaseStepCount();
-                return;
-            }
+        if (_taskQueue.Count > 0)
+            _taskQueue.Clear();
 
-            if (step is { DataId: not null, InteractionType: EInteractionType.AttuneAetherCurrent } &&
-                _gameFunctions.IsAetherCurrentUnlocked(step.DataId.Value))
-            {
-                _logger.LogInformation("Skipping step, as current is unlocked");
-                IncreaseStepCount();
-                return;
-            }
+        // reset task queue
+        _automatic = false;
+    }
 
-            QuestWork? questWork = _gameFunctions.GetQuestEx(CurrentQuest.Quest.QuestId);
-            if (questWork != null && step.MatchesQuestVariables(questWork.Value))
-            {
-                _logger.LogInformation("Skipping step, as quest variables match");
-                IncreaseStepCount();
-                return;
-            }
-        }
+    private void UpdateCurrentTask()
+    {
+        if (_gameFunctions.IsOccupied())
+            return;
 
-        if (!CurrentQuest.StepProgress.AethernetShortcutUsed && step.AethernetShortcut != null)
+        if (_currentTask == null)
         {
-            if (_gameFunctions.IsAetheryteUnlocked(step.AethernetShortcut.From) &&
-                _gameFunctions.IsAetheryteUnlocked(step.AethernetShortcut.To))
+            if (_taskQueue.TryDequeue(out ITask? upcomingTask))
             {
-                EAetheryteLocation from = step.AethernetShortcut.From;
-                EAetheryteLocation to = step.AethernetShortcut.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))
+                try
                 {
-                    if (_aetheryteData.CalculateDistance(playerPosition, territoryType, from) < 11)
+                    _logger.LogInformation("Starting task {TaskName}", upcomingTask.ToString());
+                    if (upcomingTask.Start())
                     {
-                        _logger.LogInformation("Using lifestream to teleport to {Destination}", to);
-                        _lifestreamIpc.Teleport(to);
-                        CurrentQuest = CurrentQuest with
-                        {
-                            StepProgress = CurrentQuest.StepProgress with { AethernetShortcutUsed = true }
-                        };
+                        _currentTask = upcomingTask;
+                        return;
                     }
                     else
                     {
-                        _logger.LogInformation("Moving to aethernet shortcut");
-                        _movementController.NavigateTo(EMovementType.Quest, (uint)from, _aetheryteData.Locations[from],
-                            false, true,
-                            AetheryteConverter.IsLargeAetheryte(from) ? 10.9f : 6.9f);
+                        _logger.LogTrace("Task {TaskName} was skipped", upcomingTask.ToString());
+                        return;
                     }
-
+                }
+                catch (Exception e)
+                {
+                    _logger.LogError(e, "Failed to start task {TaskName}", upcomingTask.ToString());
+                    Stop();
                     return;
                 }
             }
             else
-                _logger.LogWarning(
-                    "Aethernet shortcut not unlocked (from: {FromAetheryte}, to: {ToAetheryte}), walking manually",
-                    step.AethernetShortcut.From, step.AethernetShortcut.To);
+                return;
         }
 
-        if (step.TargetTerritoryId.HasValue && step.TerritoryId != step.TargetTerritoryId &&
-            step.TargetTerritoryId == _clientState.TerritoryType)
+        ETaskResult result;
+        try
         {
-            // we assume whatever e.g. interaction, walkto etc. we have will trigger the zone transition
-            _logger.LogInformation("Zone transition, skipping rest of step");
-            IncreaseStepCount();
-            return;
+            result = _currentTask.Update();
         }
-
-        if (step.InteractionType == EInteractionType.Jump && step.JumpDestination != null &&
-            (_clientState.LocalPlayer!.Position - step.JumpDestination.Position).Length() <=
-            (step.JumpDestination.StopDistance ?? 1f))
+        catch (Exception e)
         {
-            _logger.LogInformation("We're at the jump destination, skipping movement");
+            _logger.LogError(e, "Failed to update task {TaskName}", _currentTask.ToString());
+            Stop();
+            return;
         }
-        else if (step.Position != null)
-        {
-            float distance;
-            if (step.InteractionType == EInteractionType.WalkTo)
-                distance = step.StopDistance ?? 0.25f;
-            else
-                distance = step.StopDistance ?? MovementController.DefaultStopDistance;
-
-            var position = _clientState.LocalPlayer?.Position ?? new Vector3();
-            float actualDistance = (position - step.Position.Value).Length();
-
-            if (step.Mount == true && !_gameFunctions.HasStatusPreventingSprintOrMount())
-            {
-                _logger.LogInformation("Step explicitly wants a mount, trying to mount...");
-                if (!_condition[ConditionFlag.Mounted] && !_condition[ConditionFlag.InCombat] &&
-                    _territoryData.CanUseMount(_clientState.TerritoryType))
-                {
-                    _gameFunctions.Mount();
-                    return;
-                }
-            }
-            else if (step.Mount == false)
-            {
-                _logger.LogInformation("Step explicitly wants no mount, trying to unmount...");
-                if (_condition[ConditionFlag.Mounted])
-                {
-                    _gameFunctions.Unmount();
-                    return;
-                }
-            }
 
-            if (!step.DisableNavmesh)
-            {
-                if (step.Mount != false && actualDistance > 30f && !_condition[ConditionFlag.Mounted] &&
-                    !_condition[ConditionFlag.InCombat] && _territoryData.CanUseMount(_clientState.TerritoryType) &&
-                    !_gameFunctions.HasStatusPreventingSprintOrMount())
-                {
-                    _gameFunctions.Mount();
-                    return;
-                }
-
-                if (actualDistance > distance)
-                {
-                    _movementController.NavigateTo(EMovementType.Quest, step.DataId, step.Position.Value,
-                        fly: step.Fly == true && _gameFunctions.IsFlyingUnlockedInCurrentZone(),
-                        sprint: step.Sprint != false,
-                        stopDistance: distance);
-                    return;
-                }
-            }
-            else
-            {
-                // navmesh won't move close enough
-                if (actualDistance > distance)
-                {
-                    _movementController.NavigateTo(EMovementType.Quest, step.DataId, [step.Position.Value],
-                        fly: step.Fly == true && _gameFunctions.IsFlyingUnlockedInCurrentZone(),
-                        sprint: step.Sprint != false,
-                        stopDistance: distance);
-                    return;
-                }
-            }
-        }
-        else if (step.DataId != null && step.StopDistance != null)
+        switch (result)
         {
-            GameObject? gameObject = _gameFunctions.FindObjectByDataId(step.DataId.Value);
-            if (gameObject == null ||
-                (gameObject.Position - _clientState.LocalPlayer!.Position).Length() > step.StopDistance)
-            {
-                _logger.LogWarning("Object not found or too far away, no position so we can't move");
+            case ETaskResult.StillRunning:
                 return;
-            }
-        }
 
-        _logger.LogInformation("Running logic for {InteractionType}", step.InteractionType);
-        switch (step.InteractionType)
-        {
-            case EInteractionType.Interact:
-                if (step.DataId != null)
-                {
-                    GameObject? gameObject = _gameFunctions.FindObjectByDataId(step.DataId.Value);
-                    if (gameObject == null)
-                    {
-                        _logger.LogWarning("No game object with dataId {DataId}", step.DataId);
-                        return;
-                    }
+            case ETaskResult.SkipRemainingTasksForStep:
+                _logger.LogInformation("Result: {Result}, skipping remaining tasks for step", result);
+                _currentTask = null;
 
-                    if (!gameObject.IsTargetable && _condition[ConditionFlag.Mounted])
+                while (_taskQueue.TryDequeue(out ITask? nextTask))
+                {
+                    if (nextTask is ILastTask)
                     {
-                        _gameFunctions.Unmount();
+                        _currentTask = nextTask;
                         return;
                     }
-
-                    _gameFunctions.InteractWith(step.DataId.Value);
-
-                    // if we have any dialogue, that is handled in GameUiController
-                    if (step.DialogueChoices.Count == 0)
-                        IncreaseStepCount();
-                }
-                else
-                    _logger.LogWarning("Not interacting on current step, DataId is null");
-
-                break;
-
-            case EInteractionType.AttuneAethernetShard:
-                if (step.DataId != null)
-                {
-                    if (!_gameFunctions.IsAetheryteUnlocked((EAetheryteLocation)step.DataId.Value))
-                        _gameFunctions.InteractWith(step.DataId.Value);
-
-                    IncreaseStepCount();
-                }
-
-                break;
-
-            case EInteractionType.AttuneAetheryte:
-                if (step.DataId != null)
-                {
-                    if (!_gameFunctions.IsAetheryteUnlocked((EAetheryteLocation)step.DataId.Value))
-                        _gameFunctions.InteractWith(step.DataId.Value);
-
-                    IncreaseStepCount();
-                }
-
-                break;
-
-            case EInteractionType.AttuneAetherCurrent:
-                if (step.DataId != null)
-                {
-                    _logger.LogInformation(
-                        "{AetherCurrentId} is unlocked = {Unlocked}",
-                        step.AetherCurrentId,
-                        _gameFunctions.IsAetherCurrentUnlocked(step.AetherCurrentId.GetValueOrDefault()));
-                    if (step.AetherCurrentId == null ||
-                        !_gameFunctions.IsAetherCurrentUnlocked(step.AetherCurrentId.Value))
-                        _gameFunctions.InteractWith(step.DataId.Value);
-
-                    IncreaseStepCount();
-                }
-
-                break;
-
-            case EInteractionType.WalkTo:
-                IncreaseStepCount();
-                break;
-
-            case EInteractionType.UseItem:
-                if (_gameFunctions.Unmount())
-                    return;
-
-                if (step is { DataId: not null, ItemId: not null, GroundTarget: true })
-                {
-                    _gameFunctions.UseItemOnGround(step.DataId.Value, step.ItemId.Value);
-                    IncreaseStepCount();
-                }
-                else if (step is { DataId: not null, ItemId: not null })
-                {
-                    _gameFunctions.UseItem(step.DataId.Value, step.ItemId.Value);
-                    IncreaseStepCount();
-                }
-                else if (step.ItemId != null)
-                {
-                    _gameFunctions.UseItem(step.ItemId.Value);
-                    IncreaseStepCount();
                 }
 
-                break;
+                return;
 
-            case EInteractionType.Combat:
-                if (_gameFunctions.Unmount())
-                    return;
+            case ETaskResult.TaskComplete:
+                _logger.LogInformation("Result: {Result}, remaining tasks: {RemainingTaskCount}", result,
+                    _taskQueue.Count);
+                _currentTask = null;
 
-                if (step.EnemySpawnType != null)
-                {
-                    if (step is { DataId: not null, EnemySpawnType: EEnemySpawnType.AfterInteraction })
-                        _gameFunctions.InteractWith(step.DataId.Value);
-
-                    if (step is { DataId: not null, ItemId: not null, EnemySpawnType: EEnemySpawnType.AfterItemUse })
-                        _gameFunctions.UseItem(step.DataId.Value, step.ItemId.Value);
+                // handled in next update
+                return;
 
-                    // next sequence should trigger automatically
-                    IncreaseStepCount();
-                }
+            case ETaskResult.NextStep:
+                _logger.LogInformation("Result: {Result}", result);
+                IncreaseStepCount(true);
+                return;
 
-                break;
+            case ETaskResult.End:
+                _logger.LogInformation("Result: {Result}", result);
+                Stop();
+                return;
+        }
+    }
 
-            case EInteractionType.Emote:
-                if (step is { DataId: not null, Emote: not null })
-                {
-                    _gameFunctions.UseEmote(step.DataId.Value, step.Emote.Value);
-                    IncreaseStepCount();
-                }
-                else if (step.Emote != null)
-                {
-                    _gameFunctions.UseEmote(step.Emote.Value);
-                    IncreaseStepCount();
-                }
+    public void ExecuteNextStep(bool automatic)
+    {
+        Stop();
+        _automatic = automatic;
 
-                break;
+        (QuestSequence? seq, QuestStep? step) = GetNextStep();
+        if (CurrentQuest == null || seq == null || step == null)
+        {
+            _logger.LogWarning("Could not retrieve next quest step, not doing anything");
+            return;
+        }
 
-            case EInteractionType.Say:
-                if (_condition[ConditionFlag.Mounted])
-                {
-                    _gameFunctions.Unmount();
-                    return;
-                }
+        var newTasks = _taskFactories
+            .SelectMany(x =>
+            {
+                IList<ITask> tasks = x.CreateAllTasks(CurrentQuest.Quest, seq, step).ToList();
 
-                if (step.ChatMessage != null)
+                if (_logger.IsEnabled(LogLevel.Trace))
                 {
-                    string? excelString = _gameFunctions.GetDialogueText(CurrentQuest.Quest,
-                        step.ChatMessage.ExcelSheet,
-                        step.ChatMessage.Key);
-                    if (excelString == null)
-                        return;
+                    string factoryName = x.GetType().FullName ?? x.GetType().Name;
+                    if (factoryName.Contains('.', StringComparison.Ordinal))
+                        factoryName = factoryName[(factoryName.LastIndexOf('.') + 1)..];
 
-                    _gameFunctions.ExecuteCommand($"/say {excelString}");
-                    IncreaseStepCount();
+                    _logger.LogTrace("Factory {FactoryName} created Task {TaskNames}",
+                        factoryName, string.Join(", ", tasks.Select(y => y.ToString())));
                 }
 
-                break;
-
-            case EInteractionType.WaitForObjectAtPosition:
-                if (step is { DataId: not null, Position: not null } &&
-                    !_gameFunctions.IsObjectAtPosition(step.DataId.Value, step.Position.Value))
-                {
-                    return;
-                }
-
-                IncreaseStepCount();
-                break;
-
-            case EInteractionType.WaitForManualProgress:
-                // something needs to be done manually, the next sequence will be picked up automatically
-                break;
-
-            case EInteractionType.Duty:
-                if (step.ContentFinderConditionId != null)
-                    _gameFunctions.OpenDutyFinder(step.ContentFinderConditionId.Value);
-
-                break;
-
-            case EInteractionType.SinglePlayerDuty:
-                // TODO: Disable YesAlready, interact with NPC to open dialog, restore YesAlready
-                // TODO: also implement check for territory blacklist
-                break;
-
-            case EInteractionType.Jump:
-                if (step.JumpDestination != null && !_condition[ConditionFlag.Jumping])
-                {
-                    float stopDistance = step.JumpDestination.StopDistance ?? 1f;
-                    if ((_clientState.LocalPlayer!.Position - step.JumpDestination.Position).Length() <= stopDistance)
-                        IncreaseStepCount();
-                    else
-                    {
-                        _movementController.NavigateTo(EMovementType.Quest, step.DataId,
-                            [step.JumpDestination.Position], false, false,
-                            step.JumpDestination.StopDistance ?? stopDistance);
-                        _framework.RunOnTick(() => ActionManager.Instance()->UseAction(ActionType.GeneralAction, 2),
-                            TimeSpan.FromSeconds(step.JumpDestination.DelaySeconds ?? 0.5f));
-                    }
-                }
+                return tasks;
+            })
+            .ToList();
+        if (newTasks.Count == 0)
+        {
+            _logger.LogInformation("Nothing to execute for step?");
+            return;
+        }
 
-                break;
+        foreach (var task in newTasks)
+            _taskQueue.Enqueue(task);
+    }
 
-            case EInteractionType.ShouldBeAJump:
-            case EInteractionType.Instruction:
-                // Need to manually forward
-                break;
+    public IList<string> GetRemainingTaskNames() =>
+        _taskQueue.Select(x => x.ToString() ?? "?").ToList();
 
-            default:
-                _logger.LogWarning("Action '{InteractionType}' is not implemented", step.InteractionType);
-                break;
-        }
+    public string ToStatString()
+    {
+        return _currentTask == null ? $"- (+{_taskQueue.Count})" : $"{_currentTask} (+{_taskQueue.Count})";
     }
 
     public sealed record QuestProgress(
@@ -657,8 +406,7 @@ internal sealed class QuestController
         }
     }
 
+    // TODO is this still required?
     public sealed record StepProgress(
-        bool AetheryteShortcutUsed = false,
-        bool AethernetShortcutUsed = false,
         int DialogueChoicesSelected = 0);
 }
diff --git a/Questionable/Controller/Steps/BaseFactory/AethernetShortcut.cs b/Questionable/Controller/Steps/BaseFactory/AethernetShortcut.cs
new file mode 100644 (file)
index 0000000..a1d1faf
--- /dev/null
@@ -0,0 +1,120 @@
+using System;
+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) : ITask
+    {
+        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
+                    {
+                        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.CalculateDistance(clientState.LocalPlayer?.Position ?? Vector3.Zero,
+                    clientState.TerritoryType, To) > 11)
+                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
new file mode 100644 (file)
index 0000000..5220876
--- /dev/null
@@ -0,0 +1,96 @@
+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) : 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);
+            return [new WaitConditionTask(gameFunctions.CanTeleport, "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) : ITask
+    {
+        private DateTime _continueAt;
+
+        public QuestStep Step { get; set; } = null!;
+        public EAetheryteLocation TargetAetheryte { get; set; }
+
+        public ITask With(QuestStep step, EAetheryteLocation targetAetheryte)
+        {
+            Step = step;
+            TargetAetheryte = targetAetheryte;
+            return this;
+        }
+
+        public bool Start()
+        {
+            _continueAt = DateTime.Now.AddSeconds(8);
+            ushort territoryType = clientState.TerritoryType;
+            if (Step.TerritoryId == territoryType)
+            {
+                Vector3 pos = clientState.LocalPlayer!.Position;
+                if (aetheryteData.CalculateDistance(pos, territoryType, TargetAetheryte) < 11 ||
+                    (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 == Step.TerritoryId)
+                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
new file mode 100644 (file)
index 0000000..a3d6e1e
--- /dev/null
@@ -0,0 +1,168 @@
+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.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)
+    {
+        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: {Step.TerritoryId}");
+            yield return new WaitConditionTask(() => movementController.IsNavmeshReady, "Wait(navmesh ready)");
+
+            float distance;
+            if (Step.InteractionType == EInteractionType.WalkTo)
+                distance = Step.StopDistance ?? 0.25f;
+            else
+                distance = Step.StopDistance ?? MovementController.DefaultStopDistance;
+
+            var position = clientState.LocalPlayer?.Position ?? new Vector3();
+            float actualDistance = (position - Destination).Length();
+
+            if (Step.Mount == true)
+                yield return serviceProvider.GetRequiredService<MountTask>().With(Step.TerritoryId);
+            else if (Step.Mount == false)
+                yield return serviceProvider.GetRequiredService<UnmountTask>();
+
+            if (!Step.DisableNavmesh)
+            {
+                if (Step.Mount == null && actualDistance > 30f)
+                    yield return serviceProvider.GetRequiredService<MountTask>().With(Step.TerritoryId);
+
+                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);
+                        });
+                }
+            }
+            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()
+        {
+            GameObject? 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
new file mode 100644 (file)
index 0000000..6d288d5
--- /dev/null
@@ -0,0 +1,92 @@
+using System;
+using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
+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.Count == 0)
+                return null;
+
+            if (step.SkipIf.Contains(ESkipCondition.Never))
+                return null;
+
+            return serviceProvider.GetRequiredService<CheckTask>()
+                .With(step, quest.QuestId);
+        }
+    }
+
+    internal sealed class CheckTask(
+        ILogger<CheckTask> logger,
+        GameFunctions gameFunctions) : ITask
+    {
+        public QuestStep Step { get; set; } = null!;
+        public ushort QuestId { get; set; }
+
+        public ITask With(QuestStep step, ushort questId)
+        {
+            Step = step;
+            QuestId = questId;
+            return this;
+        }
+
+        public bool Start()
+        {
+            logger.LogInformation("Checking skip conditions; {ConfiguredConditions}", string.Join(",", Step.SkipIf));
+
+            if (Step.SkipIf.Contains(ESkipCondition.FlyingUnlocked) &&
+                gameFunctions.IsFlyingUnlocked(Step.TerritoryId))
+            {
+                logger.LogInformation("Skipping step, as flying is unlocked");
+                return true;
+            }
+
+            if (Step.SkipIf.Contains(ESkipCondition.FlyingLocked) &&
+                !gameFunctions.IsFlyingUnlocked(Step.TerritoryId))
+            {
+                logger.LogInformation("Skipping step, as flying is locked");
+                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))
+            {
+                logger.LogInformation("Skipping step, as quest variables match");
+                return true;
+            }
+
+            return false;
+        }
+
+        public ETaskResult Update() => ETaskResult.SkipRemainingTasksForStep;
+
+        public override string ToString() => $"CheckSkip({string.Join(", ", Step.SkipIf)})";
+    }
+}
diff --git a/Questionable/Controller/Steps/BaseFactory/StepDisabled.cs b/Questionable/Controller/Steps/BaseFactory/StepDisabled.cs
new file mode 100644 (file)
index 0000000..ecb367b
--- /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.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
new file mode 100644 (file)
index 0000000..01d5c75
--- /dev/null
@@ -0,0 +1,134 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Numerics;
+using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
+using Microsoft.Extensions.DependencyInjection;
+using Questionable.Controller.Steps.BaseTasks;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.BaseFactory;
+
+internal static class WaitAtEnd
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.CompletionQuestVariablesFlags.Count == 6)
+            {
+                var task = serviceProvider.GetRequiredService<WaitForCompletionFlags>()
+                    .With(quest, step);
+                var delay = serviceProvider.GetRequiredService<WaitDelay>();
+                return [task, delay, new NextStep()];
+            }
+
+            switch (step.InteractionType)
+            {
+                case EInteractionType.Combat:
+                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 [new NextStep()];
+
+                case EInteractionType.WaitForObjectAtPosition:
+                    return [serviceProvider.GetRequiredService<WaitObjectAtPosition>(), new NextStep()];
+
+                default:
+                    return [serviceProvider.GetRequiredService<WaitDelay>(), new NextStep()];
+            }
+        }
+
+        public ITask CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+            => throw new InvalidOperationException();
+    }
+
+    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)
+                ? ETaskResult.TaskComplete
+                : ETaskResult.StillRunning;
+        }
+
+        public override string ToString() =>
+            $"WaitCF({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 bool Start() => true;
+
+        public ETaskResult Update() =>
+            gameFunctions.IsObjectAtPosition(DataId, Destination)
+                ? ETaskResult.TaskComplete
+                : ETaskResult.StillRunning;
+
+        public override string ToString() =>
+            $"WaitObj({DataId} at {Destination.ToString("G", CultureInfo.InvariantCulture)})";
+    }
+
+    internal sealed class NextStep : ILastTask
+    {
+        public bool Start() => true;
+
+        public ETaskResult Update() => ETaskResult.NextStep;
+
+        public override string ToString() => "Next Step";
+    }
+
+    internal sealed class EndAutomation : ILastTask
+    {
+        public bool Start() => true;
+
+        public ETaskResult Update() => ETaskResult.End;
+
+        public override string ToString() => "End automation";
+    }
+}
diff --git a/Questionable/Controller/Steps/BaseFactory/ZoneChange.cs b/Questionable/Controller/Steps/BaseFactory/ZoneChange.cs
new file mode 100644 (file)
index 0000000..dbb89ca
--- /dev/null
@@ -0,0 +1,36 @@
+using System;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.BaseFactory;
+
+internal static class ZoneChange
+{
+    internal sealed class Factory : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            return null;
+        }
+    }
+
+    internal sealed class WaitForZone : ITask
+    {
+        /* TODO: Unsure when this would evne be needed again, this should probably be moved to AFTER walkTo/interacting
+
+        if (step.TargetTerritoryId.HasValue && step.TerritoryId != step.TargetTerritoryId &&
+            step.TargetTerritoryId == _clientState.TerritoryType)
+        {
+            // we assume whatever e.g. interaction, walkto etc. we have will trigger the zone transition
+            _logger.LogInformation("Zone transition, skipping rest of step");
+            IncreaseStepCount();
+            return;
+        }
+         */
+        public bool Start() => throw new NotImplementedException();
+
+        public ETaskResult Update() => throw new NotImplementedException();
+
+        public override string ToString() => "WaitForZone";
+    }
+}
diff --git a/Questionable/Controller/Steps/BaseTasks/AbstractDelayedTask.cs b/Questionable/Controller/Steps/BaseTasks/AbstractDelayedTask.cs
new file mode 100644 (file)
index 0000000..b43ee95
--- /dev/null
@@ -0,0 +1,37 @@
+using System;
+
+namespace Questionable.Controller.Steps.BaseTasks;
+
+internal abstract class AbstractDelayedTask : ITask
+{
+    protected readonly TimeSpan Delay;
+    private DateTime _continueAt;
+
+    protected AbstractDelayedTask(TimeSpan delay)
+    {
+        Delay = delay;
+    }
+
+    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
new file mode 100644 (file)
index 0000000..1893804
--- /dev/null
@@ -0,0 +1,61 @@
+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,
+    ILogger<MountTask> logger) : ITask
+{
+    private ushort _territoryId;
+    private bool _mountTriggered;
+
+    public ITask With(ushort territoryId)
+    {
+        _territoryId = territoryId;
+        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.HasStatusPreventingSprintOrMount())
+            return false;
+
+        logger.LogInformation("Step wants a mount, trying to mount in territory {Id}...", _territoryId);
+        if (!condition[ConditionFlag.InCombat])
+        {
+            _mountTriggered = gameFunctions.Mount();
+            return true;
+        }
+
+        return false;
+    }
+
+    public ETaskResult Update()
+    {
+        if (!_mountTriggered)
+        {
+            _mountTriggered = gameFunctions.Mount();
+            return ETaskResult.StillRunning;
+        }
+
+        return condition[ConditionFlag.Mounted]
+            ? ETaskResult.TaskComplete
+            : ETaskResult.StillRunning;
+    }
+
+    public override string ToString() => "Mount";
+}
diff --git a/Questionable/Controller/Steps/BaseTasks/UnmountTask.cs b/Questionable/Controller/Steps/BaseTasks/UnmountTask.cs
new file mode 100644 (file)
index 0000000..ede325b
--- /dev/null
@@ -0,0 +1,36 @@
+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;
+
+    public bool Start()
+    {
+        if (!condition[ConditionFlag.Mounted])
+            return false;
+
+        logger.LogInformation("Step explicitly wants no mount, trying to unmount...");
+        _unmountTriggered = gameFunctions.Unmount();
+        return true;
+    }
+
+    public ETaskResult Update()
+    {
+        if (!_unmountTriggered)
+        {
+            _unmountTriggered = gameFunctions.Unmount();
+            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
new file mode 100644 (file)
index 0000000..93ad10c
--- /dev/null
@@ -0,0 +1,12 @@
+using System;
+
+namespace Questionable.Controller.Steps.BaseTasks;
+
+internal sealed class WaitConditionTask(Func<bool> predicate, string description) : ITask
+{
+    public bool Start() => predicate();
+
+    public ETaskResult Update() => predicate() ? ETaskResult.TaskComplete : ETaskResult.StillRunning;
+
+    public override string ToString() => description;
+}
diff --git a/Questionable/Controller/Steps/ETaskResult.cs b/Questionable/Controller/Steps/ETaskResult.cs
new file mode 100644 (file)
index 0000000..647e6ae
--- /dev/null
@@ -0,0 +1,16 @@
+namespace Questionable.Controller.Steps;
+
+internal enum ETaskResult
+{
+    StillRunning,
+
+    TaskComplete,
+
+    /// <summary>
+    /// This step is complete, regardless of what any other following tasks would do.
+    /// </summary>
+    SkipRemainingTasksForStep,
+
+    NextStep,
+    End,
+}
diff --git a/Questionable/Controller/Steps/ILastTask.cs b/Questionable/Controller/Steps/ILastTask.cs
new file mode 100644 (file)
index 0000000..66cf214
--- /dev/null
@@ -0,0 +1,6 @@
+namespace Questionable.Controller.Steps;
+
+internal interface ILastTask : ITask
+{
+    
+}
diff --git a/Questionable/Controller/Steps/ITask.cs b/Questionable/Controller/Steps/ITask.cs
new file mode 100644 (file)
index 0000000..8354406
--- /dev/null
@@ -0,0 +1,11 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Questionable.Controller.Steps;
+
+internal interface ITask
+{
+    bool Start();
+
+    ETaskResult Update();
+}
diff --git a/Questionable/Controller/Steps/ITaskFactory.cs b/Questionable/Controller/Steps/ITaskFactory.cs
new file mode 100644 (file)
index 0000000..159d5c1
--- /dev/null
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps;
+
+internal interface ITaskFactory
+{
+    ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step);
+
+    IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
+    {
+        var task = CreateTask(quest, sequence, step);
+        if (task != null)
+            yield return task;
+    }
+}
diff --git a/Questionable/Controller/Steps/InteractionFactory/AetherCurrent.cs b/Questionable/Controller/Steps/InteractionFactory/AetherCurrent.cs
new file mode 100644 (file)
index 0000000..02e7d5f
--- /dev/null
@@ -0,0 +1,59 @@
+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 AetherCurrent
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : 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);
+
+            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
new file mode 100644 (file)
index 0000000..1897615
--- /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.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
new file mode 100644 (file)
index 0000000..8f59a3d
--- /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.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
new file mode 100644 (file)
index 0000000..969b805
--- /dev/null
@@ -0,0 +1,47 @@
+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);
+                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
new file mode 100644 (file)
index 0000000..adb6d59
--- /dev/null
@@ -0,0 +1,44 @@
+using System;
+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) : ITask
+    {
+        public uint ContentFinderConditionId { get; set; }
+
+        public ITask With(uint contentFinderConditionId)
+        {
+            ContentFinderConditionId = contentFinderConditionId;
+            return this;
+        }
+
+        public bool Start()
+        {
+            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
new file mode 100644 (file)
index 0000000..8ecb4f5
--- /dev/null
@@ -0,0 +1,77 @@
+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 != 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(GameFunctions gameFunctions) : 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()
+        {
+            gameFunctions.UseEmote(DataId, Emote);
+            return true;
+        }
+
+        public override string ToString() => $"Emote({Emote} on {DataId})";
+    }
+
+    internal sealed class Use(GameFunctions gameFunctions) : AbstractDelayedTask
+    {
+        public EEmote Emote { get; set; }
+
+        public ITask With(EEmote emote)
+        {
+            Emote = emote;
+            return this;
+        }
+
+        protected override bool StartInternal()
+        {
+            gameFunctions.UseEmote(Emote);
+            return true;
+        }
+
+        public override string ToString() => $"Emote({Emote})";
+    }
+}
diff --git a/Questionable/Controller/Steps/InteractionFactory/Interact.cs b/Questionable/Controller/Steps/InteractionFactory/Interact.cs
new file mode 100644 (file)
index 0000000..fb7db65
--- /dev/null
@@ -0,0 +1,99 @@
+using System;
+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.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Controller.Steps.InteractionFactory;
+
+internal static class Interact
+{
+    internal sealed class Factory(IServiceProvider serviceProvider) : ITaskFactory
+    {
+        public ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
+        {
+            if (step.InteractionType != EInteractionType.Interact)
+                return null;
+
+            ArgumentNullException.ThrowIfNull(step.DataId);
+
+            return serviceProvider.GetRequiredService<DoInteract>().With(step.DataId.Value);
+        }
+    }
+
+    internal sealed class DoInteract(GameFunctions gameFunctions, ICondition condition, ILogger<DoInteract> logger)
+        : ITask
+    {
+        private bool _interacted;
+        private DateTime _continueAt = DateTime.MinValue;
+
+        private uint DataId { get; set; }
+
+        public ITask With(uint dataId)
+        {
+            DataId = dataId;
+            return this;
+        }
+
+        public bool Start()
+        {
+            GameObject? 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])
+            {
+                gameFunctions.Unmount();
+                _continueAt = DateTime.Now.AddSeconds(0.5);
+                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 (!_interacted)
+            {
+                GameObject? 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(GameObject gameObject)
+        {
+            if (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
new file mode 100644 (file)
index 0000000..da029ac
--- /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.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
new file mode 100644 (file)
index 0000000..9ec51c3
--- /dev/null
@@ -0,0 +1,52 @@
+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.Emote)
+                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(GameFunctions gameFunctions) : AbstractDelayedTask
+    {
+        public string ChatMessage { get; set; } = null!;
+
+        public ITask With(string chatMessage)
+        {
+            ChatMessage = chatMessage;
+            return this;
+        }
+
+        protected override bool StartInternal()
+        {
+            gameFunctions.ExecuteCommand($"/say {ChatMessage}");
+            return true;
+        }
+
+        public override string ToString() => $"Say({ChatMessage})";
+    }
+}
diff --git a/Questionable/Controller/Steps/InteractionFactory/UseItem.cs b/Questionable/Controller/Steps/InteractionFactory/UseItem.cs
new file mode 100644 (file)
index 0000000..b562baf
--- /dev/null
@@ -0,0 +1,109 @@
+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 UseItem
+{
+    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 sealed class UseOnGround(GameFunctions gameFunctions) : AbstractDelayedTask
+    {
+        public uint DataId { get; set; }
+        public uint ItemId { get; set; }
+
+        public ITask With(uint dataId, uint itemId)
+        {
+            DataId = dataId;
+            ItemId = itemId;
+            return this;
+        }
+
+        protected override bool StartInternal()
+        {
+            gameFunctions.UseItemOnGround(DataId, ItemId);
+            return true;
+        }
+
+        public override string ToString() => $"UseItem({ItemId} on ground at {DataId})";
+    }
+
+    internal sealed class UseOnObject(GameFunctions gameFunctions) : AbstractDelayedTask
+    {
+        public uint DataId { get; set; }
+        public uint ItemId { get; set; }
+
+        public ITask With(uint dataId, uint itemId)
+        {
+            DataId = dataId;
+            ItemId = itemId;
+            return this;
+        }
+
+        protected override bool StartInternal()
+        {
+            gameFunctions.UseItem(DataId, ItemId);
+            return true;
+        }
+
+        public override string ToString() => $"UseItem({ItemId} on {DataId})";
+    }
+
+    internal sealed class Use(GameFunctions gameFunctions) : AbstractDelayedTask
+    {
+        public uint ItemId { get; set; }
+
+        public ITask With(uint itemId)
+        {
+            ItemId = itemId;
+            return this;
+        }
+
+        protected override bool StartInternal()
+        {
+            gameFunctions.UseItem(ItemId);
+            return true;
+        }
+
+        public override string ToString() => $"UseItem({ItemId})";
+    }
+}
diff --git a/Questionable/Controller/Steps/TaskException.cs b/Questionable/Controller/Steps/TaskException.cs
new file mode 100644 (file)
index 0000000..41904c6
--- /dev/null
@@ -0,0 +1,20 @@
+using System;
+
+namespace Questionable.Controller.Steps;
+
+public class TaskException : Exception
+{
+    public TaskException()
+    {
+    }
+
+    public TaskException(string message)
+        : base(message)
+    {
+    }
+
+    public TaskException(string message, Exception innerException)
+        : base(message, innerException)
+    {
+    }
+}
index c0f9d2cc7b461a35fefdf737c0e7fb8ad36300b7..e2d03cf7f8d650033286e45b48f8159709412e22 100644 (file)
@@ -47,7 +47,15 @@ internal sealed class DalamudInitializer : IDisposable
     {
         _questController.Update();
         _navigationShortcutController.HandleNavigationShortcut();
-        _movementController.Update();
+
+        try
+        {
+            _movementController.Update();
+        }
+        catch (MovementController.PathfindingFailedException)
+        {
+            _questController.Stop();
+        }
     }
 
     private void ProcessCommand(string command, string arguments)
index df23085407dc7d23d6c15ec70afb0ec9da42fdc4..3ca4c08d93dfe4da407650d02f1a5fd03caf398f 100644 (file)
@@ -20,6 +20,8 @@ using FFXIVClientStructs.FFXIV.Client.System.Framework;
 using FFXIVClientStructs.FFXIV.Client.System.Memory;
 using FFXIVClientStructs.FFXIV.Client.System.String;
 using FFXIVClientStructs.FFXIV.Client.UI.Agent;
+using FFXIVClientStructs.FFXIV.Component.GUI;
+using LLib.GameUI;
 using Lumina.Excel.CustomSheets;
 using Lumina.Excel.GeneratedSheets;
 using Microsoft.Extensions.Logging;
@@ -53,11 +55,12 @@ internal sealed unsafe class GameFunctions
     private readonly ICondition _condition;
     private readonly IClientState _clientState;
     private readonly QuestRegistry _questRegistry;
+    private readonly IGameGui _gameGui;
     private readonly ILogger<GameFunctions> _logger;
 
     public GameFunctions(IDataManager dataManager, IObjectTable objectTable, ISigScanner sigScanner,
         ITargetManager targetManager, ICondition condition, IClientState clientState, QuestRegistry questRegistry,
-        ILogger<GameFunctions> logger)
+        IGameGui gameGui, ILogger<GameFunctions> logger)
     {
         _dataManager = dataManager;
         _objectTable = objectTable;
@@ -65,6 +68,7 @@ internal sealed unsafe class GameFunctions
         _condition = condition;
         _clientState = clientState;
         _questRegistry = questRegistry;
+        _gameGui = gameGui;
         _logger = logger;
         _processChatBox =
             Marshal.GetDelegateForFunctionPointer<ProcessChatBoxDelegate>(sigScanner.ScanText(Signatures.SendChat));
@@ -166,6 +170,8 @@ internal sealed unsafe class GameFunctions
     public bool IsAetheryteUnlocked(EAetheryteLocation aetheryteLocation)
         => IsAetheryteUnlocked((uint)aetheryteLocation, out _);
 
+    public bool CanTeleport() => ActionManager.Instance()->GetActionStatus(ActionType.Action, 5) == 0;
+
     public bool TeleportAetheryte(uint aetheryteId)
     {
         var status = ActionManager.Instance()->GetActionStatus(ActionType.Action, 5);
@@ -342,7 +348,7 @@ internal sealed unsafe class GameFunctions
         return null;
     }
 
-    public void InteractWith(uint dataId)
+    public bool InteractWith(uint dataId)
     {
         GameObject? gameObject = FindObjectByDataId(dataId);
         if (gameObject != null)
@@ -350,9 +356,12 @@ internal sealed unsafe class GameFunctions
             _logger.LogInformation("Setting target with {DataId} to {ObjectId}", dataId, gameObject.ObjectId);
             _targetManager.Target = gameObject;
 
-            TargetSystem.Instance()->InteractWithObject(
+            ulong result = TargetSystem.Instance()->InteractWithObject(
                 (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)gameObject.Address, false);
+            return result != 0;
         }
+
+        return false;
     }
 
     public void UseItem(uint itemId)
@@ -422,46 +431,46 @@ internal sealed unsafe class GameFunctions
         return false;
     }
 
-    public void Mount()
+    public bool Mount()
     {
-        if (!_condition[ConditionFlag.Mounted])
+        if (_condition[ConditionFlag.Mounted])
+            return true;
+
+        var playerState = PlayerState.Instance();
+        if (playerState != null && playerState->IsMountUnlocked(71))
         {
-            var playerState = PlayerState.Instance();
-            if (playerState != null && playerState->IsMountUnlocked(71))
+            if (ActionManager.Instance()->GetActionStatus(ActionType.Mount, 71) == 0)
             {
-                if (ActionManager.Instance()->GetActionStatus(ActionType.Mount, 71) == 0)
-                {
-                    _logger.LogInformation("Using SDS Fenrir as mount");
-                    ActionManager.Instance()->UseAction(ActionType.Mount, 71);
-                }
+                _logger.LogInformation("Using SDS Fenrir as mount");
+                return ActionManager.Instance()->UseAction(ActionType.Mount, 71);
             }
-            else
+        }
+        else
+        {
+            if (ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 9) == 0)
             {
-                if (ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 9) == 0)
-                {
-                    _logger.LogInformation("Using mount roulette");
-                    ActionManager.Instance()->UseAction(ActionType.GeneralAction, 9);
-                }
+                _logger.LogInformation("Using mount roulette");
+                return ActionManager.Instance()->UseAction(ActionType.GeneralAction, 9);
             }
         }
+
+        return false;
     }
 
     public bool Unmount()
     {
-        if (_condition[ConditionFlag.Mounted])
-        {
-            if (ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 23) == 0)
-            {
-                _logger.LogInformation("Unmounting...");
-                ActionManager.Instance()->UseAction(ActionType.GeneralAction, 23);
-            }
-            else
-                _logger.LogWarning("Can't unmount right now?");
+        if (!_condition[ConditionFlag.Mounted])
+            return false;
 
-            return true;
+        if (ActionManager.Instance()->GetActionStatus(ActionType.GeneralAction, 23) == 0)
+        {
+            _logger.LogInformation("Unmounting...");
+            ActionManager.Instance()->UseAction(ActionType.GeneralAction, 23);
         }
+        else
+            _logger.LogWarning("Can't unmount right now?");
 
-        return false;
+        return true;
     }
 
     public void OpenDutyFinder(uint contentFinderConditionId)
@@ -508,4 +517,19 @@ internal sealed unsafe class GameFunctions
         var questRow = _dataManager.GetExcelSheet<ContentTalk>()!.GetRow(rowId);
         return questRow?.Text?.ToString();
     }
+
+    public bool IsOccupied()
+    {
+        if (_gameGui.TryGetAddonByName("FadeMiddle", out AtkUnitBase* fade) &&
+            LAddon.IsAddonReady(fade) &&
+            fade->IsVisible)
+            return true;
+
+        return _condition[ConditionFlag.Occupied] || _condition[ConditionFlag.Occupied30] ||
+            _condition[ConditionFlag.Occupied33] || _condition[ConditionFlag.Occupied38] ||
+            _condition[ConditionFlag.Occupied39] || _condition[ConditionFlag.OccupiedInEvent] ||
+            _condition[ConditionFlag.OccupiedInQuestEvent] || _condition[ConditionFlag.OccupiedInCutSceneEvent] ||
+            _condition[ConditionFlag.Casting] || _condition[ConditionFlag.Unknown57] ||
+            _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
+    }
 }
diff --git a/Questionable/GlobalSuppressions.cs b/Questionable/GlobalSuppressions.cs
deleted file mode 100644 (file)
index adfec4f..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-using System.Diagnostics.CodeAnalysis;
-
-[assembly: SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global",
-    Justification = "Properties are used for serialization",
-    Scope = "namespaceanddescendants",
-    Target = "Questionable.Model.V1")]
-[assembly: SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global",
-    Justification = "Properties are used for serialization",
-    Scope = "namespaceanddescendants",
-    Target = "Questionable.Model.V1")]
index 48a10da93ac27cda52d8ad6aa140c40fb1a311e5..c7874da728893c3d17bd8396f39975357a81f3c1 100644 (file)
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
     <PropertyGroup>
         <TargetFramework>net8.0-windows</TargetFramework>
-        <Version>0.5</Version>
+        <Version>0.6</Version>
         <LangVersion>12</LangVersion>
         <Nullable>enable</Nullable>
         <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
@@ -23,7 +23,7 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="Dalamud.Extensions.MicrosoftLogging" Version="3.0.0" />
+        <PackageReference Include="Dalamud.Extensions.MicrosoftLogging" Version="4.0.1" />
         <PackageReference Include="DalamudPackager" Version="2.1.12"/>
         <PackageReference Include="JetBrains.Annotations" Version="2023.3.0" ExcludeAssets="runtime"/>
         <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
@@ -59,6 +59,6 @@
 
     <ItemGroup>
       <ProjectReference Include="..\LLib\LLib.csproj" />
-      <ProjectReference Include="..\QuestPaths\QuestPaths.csproj" Condition="'$(Configuration)' == 'Release'" />
+      <ProjectReference Include="..\QuestPaths\QuestPaths.csproj" />
     </ItemGroup>
 </Project>
index 1a6ea83d7b80de9580af0b2a6ba59e6f030aa2be..ca86fa00960506eaac788ce813640e0f7bc841a1 100644 (file)
@@ -7,8 +7,13 @@ using Dalamud.Interface.Windowing;
 using Dalamud.Plugin;
 using Dalamud.Plugin.Services;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller;
+using Questionable.Controller.Steps;
+using Questionable.Controller.Steps.BaseFactory;
+using Questionable.Controller.Steps.BaseTasks;
+using Questionable.Controller.Steps.InteractionFactory;
 using Questionable.Data;
 using Questionable.External;
 using Questionable.Windows;
@@ -32,14 +37,15 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         ICondition condition,
         IChatGui chatGui,
         ICommandManager commandManager,
-        IAddonLifecycle addonLifecycle)
+        IAddonLifecycle addonLifecycle,
+        IKeyState keyState)
     {
         ArgumentNullException.ThrowIfNull(pluginInterface);
 
         ServiceCollection serviceCollection = new();
         serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Trace)
             .ClearProviders()
-            .AddDalamudLogger(pluginLog));
+            .AddDalamudLogger(pluginLog, t => t[(t.LastIndexOf('.') + 1)..]));
         serviceCollection.AddSingleton<IDalamudPlugin>(this);
         serviceCollection.AddSingleton(pluginInterface);
         serviceCollection.AddSingleton(clientState);
@@ -53,6 +59,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddSingleton(chatGui);
         serviceCollection.AddSingleton(commandManager);
         serviceCollection.AddSingleton(addonLifecycle);
+        serviceCollection.AddSingleton(keyState);
         serviceCollection.AddSingleton(new WindowSystem(nameof(Questionable)));
         serviceCollection.AddSingleton((Configuration?)pluginInterface.GetPluginConfig() ?? new Configuration());
 
@@ -62,6 +69,39 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddSingleton<NavmeshIpc>();
         serviceCollection.AddSingleton<LifestreamIpc>();
 
+        // individual tasks
+        serviceCollection.AddTransient<MountTask>();
+        serviceCollection.AddTransient<UnmountTask>();
+
+        // tasks with factories
+        serviceCollection.AddTaskWithFactory<StepDisabled.Factory, StepDisabled.Task>();
+        serviceCollection.AddTaskWithFactory<AetheryteShortcut.Factory, AetheryteShortcut.UseAetheryteShortcut>();
+        serviceCollection.AddTaskWithFactory<SkipCondition.Factory, SkipCondition.CheckTask>();
+        serviceCollection.AddTaskWithFactory<AethernetShortcut.Factory, AethernetShortcut.UseAethernetShortcut>();
+        serviceCollection.AddTaskWithFactory<Move.Factory, Move.MoveInternal, Move.ExpectToBeNearDataId>();
+        serviceCollection.AddTransient<Move.MoveBuilder>();
+
+        serviceCollection.AddTaskWithFactory<AetherCurrent.Factory, AetherCurrent.DoAttune>();
+        serviceCollection.AddTaskWithFactory<AethernetShard.Factory, AethernetShard.DoAttune>();
+        serviceCollection.AddTaskWithFactory<Aetheryte.Factory, Aetheryte.DoAttune>();
+        serviceCollection.AddSingleton<ITaskFactory, Combat.Factory>();
+        serviceCollection.AddTaskWithFactory<Duty.Factory, Duty.OpenDutyFinder>();
+        serviceCollection.AddTaskWithFactory<Emote.Factory, Emote.UseOnObject, Emote.Use>();
+        serviceCollection.AddTaskWithFactory<Interact.Factory, Interact.DoInteract>();
+        serviceCollection.AddTaskWithFactory<Jump.Factory, Jump.DoJump>();
+        serviceCollection.AddTaskWithFactory<Say.Factory, Say.UseChat>();
+        serviceCollection.AddTaskWithFactory<UseItem.Factory, UseItem.UseOnGround, UseItem.UseOnObject, UseItem.Use>();
+
+        // TODO sort this in properly
+        serviceCollection.AddTaskWithFactory<ZoneChange.Factory, ZoneChange.WaitForZone>();
+
+        serviceCollection
+            .AddTaskWithFactory<WaitAtEnd.Factory,
+                WaitAtEnd.WaitDelay,
+                WaitAtEnd.WaitNextStepOrSequence,
+                WaitAtEnd.WaitForCompletionFlags,
+                WaitAtEnd.WaitObjectAtPosition>();
+
         serviceCollection.AddSingleton<MovementController>();
         serviceCollection.AddSingleton<QuestRegistry>();
         serviceCollection.AddSingleton<QuestController>();
diff --git a/Questionable/ServiceCollectionExtensions.cs b/Questionable/ServiceCollectionExtensions.cs
new file mode 100644 (file)
index 0000000..a97401f
--- /dev/null
@@ -0,0 +1,84 @@
+using JetBrains.Annotations;
+using Microsoft.Extensions.DependencyInjection;
+using Questionable.Controller.Steps;
+
+namespace Questionable;
+
+internal static class ServiceCollectionExtensions
+{
+    public static void AddTaskWithFactory<
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TFactory,
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TTask>(
+        this IServiceCollection serviceCollection)
+        where TFactory : class, ITaskFactory
+        where TTask : class, ITask
+    {
+        serviceCollection.AddSingleton<ITaskFactory, TFactory>();
+        serviceCollection.AddTransient<TTask>();
+    }
+
+    public static void AddTaskWithFactory<
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TFactory,
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TTask1,
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TTask2>(
+        this IServiceCollection serviceCollection)
+        where TFactory : class, ITaskFactory
+        where TTask1 : class, ITask
+        where TTask2 : class, ITask
+    {
+        serviceCollection.AddSingleton<ITaskFactory, TFactory>();
+        serviceCollection.AddTransient<TTask1>();
+        serviceCollection.AddTransient<TTask2>();
+    }
+
+    public static void AddTaskWithFactory<
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TFactory,
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TTask1,
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TTask2,
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TTask3>(
+        this IServiceCollection serviceCollection)
+        where TFactory : class, ITaskFactory
+        where TTask1 : class, ITask
+        where TTask2 : class, ITask
+        where TTask3 : class, ITask
+    {
+        serviceCollection.AddSingleton<ITaskFactory, TFactory>();
+        serviceCollection.AddTransient<TTask1>();
+        serviceCollection.AddTransient<TTask2>();
+        serviceCollection.AddTransient<TTask3>();
+    }
+
+    public static void AddTaskWithFactory<
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TFactory,
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TTask1,
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TTask2,
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TTask3,
+        [MeansImplicitUse(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+        TTask4>(
+        this IServiceCollection serviceCollection)
+        where TFactory : class, ITaskFactory
+        where TTask1 : class, ITask
+        where TTask2 : class, ITask
+        where TTask3 : class, ITask
+        where TTask4 : class, ITask
+    {
+        serviceCollection.AddSingleton<ITaskFactory, TFactory>();
+        serviceCollection.AddTransient<TTask1>();
+        serviceCollection.AddTransient<TTask2>();
+        serviceCollection.AddTransient<TTask3>();
+        serviceCollection.AddTransient<TTask4>();
+    }
+}
index 79904f5716ac9007607305d8af768cc495ef3be4..99ef7e82c16c485f14fcbb33c673a467cd2a956c 100644 (file)
@@ -9,9 +9,11 @@ using Dalamud.Plugin;
 using Dalamud.Plugin.Services;
 using FFXIVClientStructs.FFXIV.Client.Game;
 using FFXIVClientStructs.FFXIV.Client.Game.Control;
+using FFXIVClientStructs.FFXIV.Client.Game.Object;
 using FFXIVClientStructs.FFXIV.Client.UI.Agent;
 using ImGuiNET;
 using LLib.ImGui;
+using Microsoft.Extensions.Logging;
 using Questionable.Controller;
 using Questionable.Model;
 using Questionable.Model.V1;
@@ -30,11 +32,12 @@ internal sealed class DebugWindow : LWindow, IPersistableWindowConfig, IDisposab
     private readonly ITargetManager _targetManager;
     private readonly GameUiController _gameUiController;
     private readonly Configuration _configuration;
+    private readonly ILogger<DebugWindow> _logger;
 
     public DebugWindow(DalamudPluginInterface pluginInterface, WindowSystem windowSystem,
         MovementController movementController, QuestController questController, GameFunctions gameFunctions,
         IClientState clientState, IFramework framework, ITargetManager targetManager, GameUiController gameUiController,
-        Configuration configuration)
+        Configuration configuration, ILogger<DebugWindow> logger)
         : base("Questionable", ImGuiWindowFlags.AlwaysAutoResize)
     {
         _pluginInterface = pluginInterface;
@@ -47,6 +50,7 @@ internal sealed class DebugWindow : LWindow, IPersistableWindowConfig, IDisposab
         _targetManager = targetManager;
         _gameUiController = gameUiController;
         _configuration = configuration;
+        _logger = logger;
 
         IsOpen = true;
         SizeConstraints = new WindowSizeConstraints
@@ -109,23 +113,36 @@ internal sealed class DebugWindow : LWindow, IPersistableWindowConfig, IDisposab
             ImGui.EndDisabled();
             ImGui.TextUnformatted(_questController.Comment ?? "--");
 
-            var nextStep = _questController.GetNextStep();
-            ImGui.BeginDisabled(nextStep.Step == null);
-            ImGui.Text(string.Create(CultureInfo.InvariantCulture,
-                $"{nextStep.Step?.InteractionType} @ {nextStep.Step?.Position}"));
+            //var nextStep = _questController.GetNextStep();
+            //ImGui.BeginDisabled(nextStep.Step == null);
+            ImGui.Text(_questController.ToStatString());
+            //ImGui.EndDisabled();
+
             if (ImGuiComponents.IconButton(FontAwesomeIcon.Play))
             {
-                _questController.ExecuteNextStep();
+                _questController.ExecuteNextStep(true);
             }
 
             ImGui.SameLine();
 
-            if (ImGuiComponents.IconButton(FontAwesomeIcon.StepForward))
+            if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.StepForward, "Step"))
             {
-                _questController.IncreaseStepCount();
+                _questController.ExecuteNextStep(false);
             }
 
-            ImGui.EndDisabled();
+            ImGui.SameLine();
+
+            if (ImGuiComponents.IconButton(FontAwesomeIcon.Stop))
+            {
+                _movementController.Stop();
+                _questController.Stop();
+            }
+
+            if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.ArrowCircleRight, "Skip"))
+            {
+                _questController.Stop();
+                _questController.IncreaseStepCount();
+            }
         }
         else
             ImGui.Text("No active quest");
@@ -165,8 +182,10 @@ internal sealed class DebugWindow : LWindow, IPersistableWindowConfig, IDisposab
             ImGui.Separator();
             ImGui.Text(string.Create(CultureInfo.InvariantCulture,
                 $"Target: {_targetManager.Target.Name}  ({_targetManager.Target.ObjectKind}; {_targetManager.Target.DataId})"));
+
+            GameObject* gameObject = (GameObject*)_targetManager.Target.Address;
             ImGui.Text(string.Create(CultureInfo.InvariantCulture,
-                $"Distance: {(_targetManager.Target.Position - _clientState.LocalPlayer.Position).Length():F2}, Y: {_targetManager.Target.Position.Y - _clientState.LocalPlayer.Position.Y:F2}"));
+                $"Distance: {(_targetManager.Target.Position - _clientState.LocalPlayer.Position).Length():F2}, Y: {_targetManager.Target.Position.Y - _clientState.LocalPlayer.Position.Y:F2} | QM: {gameObject->NamePlateIconId}"));
 
             ImGui.BeginDisabled(!_movementController.IsNavmeshReady);
             if (!_movementController.IsPathfinding)
@@ -189,8 +208,9 @@ internal sealed class DebugWindow : LWindow, IPersistableWindowConfig, IDisposab
             ImGui.SameLine();
             if (ImGui.Button("Interact"))
             {
-                TargetSystem.Instance()->InteractWithObject(
+                ulong result = TargetSystem.Instance()->InteractWithObject(
                     (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)_targetManager.Target.Address, false);
+                _logger.LogInformation("XXXXX Interaction Result: {Result}", result);
             }
 
             ImGui.SameLine();
@@ -246,7 +266,11 @@ internal sealed class DebugWindow : LWindow, IPersistableWindowConfig, IDisposab
 
         ImGui.BeginDisabled(!_movementController.IsPathRunning);
         if (ImGui.Button("Stop Nav"))
+        {
             _movementController.Stop();
+            _questController.Stop();
+        }
+
         ImGui.EndDisabled();
 
         if (ImGui.Button("Reload Data"))
@@ -255,6 +279,16 @@ internal sealed class DebugWindow : LWindow, IPersistableWindowConfig, IDisposab
             _framework.RunOnTick(() => _gameUiController.HandleCurrentDialogueChoices(),
                 TimeSpan.FromMilliseconds(200));
         }
+
+        var remainingTasks = _questController.GetRemainingTaskNames();
+        if (remainingTasks.Count > 0)
+        {
+            ImGui.Separator();
+            ImGui.BeginDisabled();
+            foreach (var task in remainingTasks)
+                ImGui.TextUnformatted(task);
+            ImGui.EndDisabled();
+        }
     }
 
     public void Dispose()
index 7c5ad37040b6575e84471066982ec8511b15aa23..e4dec3d5ab5ba2acaf4acc167d7caafc184709ac 100644 (file)
@@ -4,9 +4,9 @@
     "net8.0-windows7.0": {
       "Dalamud.Extensions.MicrosoftLogging": {
         "type": "Direct",
-        "requested": "[3.0.0, )",
-        "resolved": "3.0.0",
-        "contentHash": "jWK3r/cZUXN8H9vHf78gEzeRmMk4YAbCUYzLcTqUAcega8unUiFGwYy+iOjVYJ9urnr9r+hk+vBi1y9wyv+e7Q==",
+        "requested": "[4.0.1, )",
+        "resolved": "4.0.1",
+        "contentHash": "fMEL2ajtF/30SBBku7vMyG0yye5eHN/A9fgT//1CEjUth/Wz2CYco5Ehye21T8KN1IuAPwoqJuu49rB71j+8ug==",
         "dependencies": {
           "Microsoft.Extensions.Logging": "8.0.0"
         }