Handle 'Action canceled, you are under attack' while e.g. talking to an NPC v3.4
authorLiza Carvelli <liza@carvel.li>
Tue, 17 Sep 2024 17:37:28 +0000 (19:37 +0200)
committerLiza Carvelli <liza@carvel.li>
Tue, 17 Sep 2024 17:37:28 +0000 (19:37 +0200)
13 files changed:
Directory.Build.targets
Questionable.Model/Questing/EEnemySpawnType.cs
Questionable/Controller/CombatController.cs
Questionable/Controller/CommandHandler.cs
Questionable/Controller/GatheringController.cs
Questionable/Controller/MiniTaskController.cs
Questionable/Controller/MovementController.cs
Questionable/Controller/QuestController.cs
Questionable/Controller/Steps/Common/Mount.cs
Questionable/Controller/Steps/ITask.cs
Questionable/Controller/Steps/Interactions/Combat.cs
Questionable/Controller/Steps/Shared/MoveTo.cs
Questionable/Controller/Steps/TaskQueue.cs [new file with mode: 0644]

index aa6b3b275c14838cfffabd757d55ab686520a26e..7a37148aa3ec8991829ccc1a015ffd77694b8379 100644 (file)
@@ -1,5 +1,5 @@
 <Project>
     <PropertyGroup>
-        <Version>3.3</Version>
+        <Version>3.4</Version>
     </PropertyGroup>
 </Project>
index aaf9d789734c97f4cf07d660bc3920a91bb579e6..b7e5332e2473f9070110795746b4c4d8f005b201 100644 (file)
@@ -13,4 +13,5 @@ public enum EEnemySpawnType
     AutoOnEnterArea,
     OverworldEnemies,
     FateEnemies,
+    QuestInterruption,
 }
index 26c4fa5ce0dfd90d545f8ec493348263fad82697..d3572780cf055e6ba454ea43ae9199f26ca95efd 100644 (file)
@@ -75,6 +75,7 @@ internal sealed class CombatController : IDisposable
                 Module = combatModule,
                 Data = combatData,
             };
+            _wasInCombat = combatData.SpawnType == EEnemySpawnType.QuestInterruption;
             return true;
         }
         else
@@ -86,7 +87,9 @@ internal sealed class CombatController : IDisposable
         if (_currentFight == null)
             return EStatus.Complete;
 
-        if (_movementController.IsPathfinding || _movementController.IsPathRunning || _movementController.MovementStartedAt > DateTime.Now.AddSeconds(-1))
+        if (_movementController.IsPathfinding ||
+            _movementController.IsPathRunning ||
+            _movementController.MovementStartedAt > DateTime.Now.AddSeconds(-1))
             return EStatus.Moving;
 
         var target = _targetManager.Target;
@@ -111,6 +114,8 @@ internal sealed class CombatController : IDisposable
         else
         {
             var nextTarget = FindNextTarget();
+            _logger.LogInformation("NT → {NT}", nextTarget);
+
             if (nextTarget is { IsDead: false })
                 SetTarget(nextTarget);
         }
@@ -335,7 +340,7 @@ internal sealed class CombatController : IDisposable
 
     public sealed class CombatData
     {
-        public required ElementId ElementId { get; init; }
+        public required ElementId? ElementId { get; init; }
         public required EEnemySpawnType SpawnType { get; init; }
         public required List<uint> KillEnemyDataIds { get; init; }
         public required List<ComplexCombatData> ComplexCombatDatas { get; init; }
@@ -345,6 +350,7 @@ internal sealed class CombatController : IDisposable
 
     public enum EStatus
     {
+        NotStarted,
         InCombat,
         Moving,
         Complete,
index 776f615af2b7aac5e31632ce2c62bf1ac9fb76b5..d532b0ea5cdb8bce5f3ac26507fd855d02e28e4d 100644 (file)
@@ -122,6 +122,10 @@ internal sealed class CommandHandler : IDisposable
                 PrintMountId();
                 break;
 
+            case "handle-interrupt":
+                _questController.InterruptQueueWithCombat();
+                break;
+
             case "":
                 _questWindow.Toggle();
                 break;
index 77860f7e772e0a53c3a6013a1d80e8977af68f13..bbad03d3d48ce5893f914680a9065dcfb74b6adc 100644 (file)
@@ -129,7 +129,7 @@ internal sealed unsafe class GatheringController : MiniTaskController<GatheringC
             return EStatus.Complete;
         }
 
-        if (_currentTask == null && _taskQueue.Count == 0)
+        if (_taskQueue.AllTasksComplete)
             GoToNextNode();
 
         UpdateCurrentTask();
@@ -141,8 +141,7 @@ internal sealed unsafe class GatheringController : MiniTaskController<GatheringC
     public override void Stop(string label)
     {
         _currentRequest = null;
-        _currentTask = null;
-        _taskQueue.Clear();
+        _taskQueue.Reset();
     }
 
     private void GoToNextNode()
@@ -150,7 +149,7 @@ internal sealed unsafe class GatheringController : MiniTaskController<GatheringC
         if (_currentRequest == null)
             return;
 
-        if (_taskQueue.Count > 0)
+        if (!_taskQueue.AllTasksComplete)
             return;
 
         var director = UIState.Instance()->DirectorTodo.Director;
@@ -267,8 +266,8 @@ internal sealed unsafe class GatheringController : MiniTaskController<GatheringC
 
     public override IList<string> GetRemainingTaskNames()
     {
-        if (_currentTask != null)
-            return [_currentTask.ToString() ?? "?", .. base.GetRemainingTaskNames()];
+        if (_taskQueue.CurrentTask is {} currentTask)
+            return [currentTask.ToString() ?? "?", .. base.GetRemainingTaskNames()];
         else
             return base.GetRemainingTaskNames();
     }
@@ -277,10 +276,10 @@ internal sealed unsafe class GatheringController : MiniTaskController<GatheringC
     {
         if (_revisitRegex.IsMatch(message.TextValue))
         {
-            if (_currentTask is IRevisitAware currentTaskRevisitAware)
+            if (_taskQueue.CurrentTask is IRevisitAware currentTaskRevisitAware)
                 currentTaskRevisitAware.OnRevisit();
 
-            foreach (ITask task in _taskQueue)
+            foreach (ITask task in _taskQueue.RemainingTasks)
             {
                 if (task is IRevisitAware taskRevisitAware)
                     taskRevisitAware.OnRevisit();
index 8d905b952b8ade56e6a21b654765df11b9df4b3f..0ec7bd675f4f1014560f8c8a3ee0d479d9746075 100644 (file)
@@ -12,11 +12,9 @@ internal abstract class MiniTaskController<T>
 {
     protected readonly IChatGui _chatGui;
     protected readonly ILogger<T> _logger;
+    protected readonly TaskQueue _taskQueue = new();
 
-    protected readonly Queue<ITask> _taskQueue = new();
-    protected ITask? _currentTask;
-
-    public MiniTaskController(IChatGui chatGui, ILogger<T> logger)
+    protected MiniTaskController(IChatGui chatGui, ILogger<T> logger)
     {
         _chatGui = chatGui;
         _logger = logger;
@@ -24,7 +22,7 @@ internal abstract class MiniTaskController<T>
 
     protected virtual void UpdateCurrentTask()
     {
-        if (_currentTask == null)
+        if (_taskQueue.CurrentTask == null)
         {
             if (_taskQueue.TryDequeue(out ITask? upcomingTask))
             {
@@ -33,7 +31,7 @@ internal abstract class MiniTaskController<T>
                     _logger.LogInformation("Starting task {TaskName}", upcomingTask.ToString());
                     if (upcomingTask.Start())
                     {
-                        _currentTask = upcomingTask;
+                        _taskQueue.CurrentTask = upcomingTask;
                         return;
                     }
                     else
@@ -58,13 +56,13 @@ internal abstract class MiniTaskController<T>
         ETaskResult result;
         try
         {
-            result = _currentTask.Update();
+            result = _taskQueue.CurrentTask.Update();
         }
         catch (Exception e)
         {
-            _logger.LogError(e, "Failed to update task {TaskName}", _currentTask.ToString());
+            _logger.LogError(e, "Failed to update task {TaskName}", _taskQueue.CurrentTask.ToString());
             _chatGui.PrintError(
-                $"[Questionable] Failed to update task '{_currentTask}', please check /xllog for details.");
+                $"[Questionable] Failed to update task '{_taskQueue.CurrentTask}', please check /xllog for details.");
             Stop("Task failed to update");
             return;
         }
@@ -76,14 +74,14 @@ internal abstract class MiniTaskController<T>
 
             case ETaskResult.SkipRemainingTasksForStep:
                 _logger.LogInformation("{Task} → {Result}, skipping remaining tasks for step",
-                    _currentTask, result);
-                _currentTask = null;
+                    _taskQueue.CurrentTask, result);
+                _taskQueue.CurrentTask = null;
 
                 while (_taskQueue.TryDequeue(out ITask? nextTask))
                 {
                     if (nextTask is ILastTask or Gather.SkipMarker)
                     {
-                        _currentTask = nextTask;
+                        _taskQueue.CurrentTask = nextTask;
                         return;
                     }
                 }
@@ -92,27 +90,27 @@ internal abstract class MiniTaskController<T>
 
             case ETaskResult.TaskComplete:
                 _logger.LogInformation("{Task} → {Result}, remaining tasks: {RemainingTaskCount}",
-                    _currentTask, result, _taskQueue.Count);
+                    _taskQueue.CurrentTask, result, _taskQueue.RemainingTasks.Count());
 
-                OnTaskComplete(_currentTask);
+                OnTaskComplete(_taskQueue.CurrentTask);
 
-                _currentTask = null;
+                _taskQueue.CurrentTask = null;
 
                 // handled in next update
                 return;
 
             case ETaskResult.NextStep:
-                _logger.LogInformation("{Task} → {Result}", _currentTask, result);
+                _logger.LogInformation("{Task} → {Result}", _taskQueue.CurrentTask, result);
 
-                var lastTask = (ILastTask)_currentTask;
-                _currentTask = null;
+                var lastTask = (ILastTask)_taskQueue.CurrentTask;
+                _taskQueue.CurrentTask = null;
 
                 OnNextStep(lastTask);
                 return;
 
             case ETaskResult.End:
-                _logger.LogInformation("{Task} → {Result}", _currentTask, result);
-                _currentTask = null;
+                _logger.LogInformation("{Task} → {Result}", _taskQueue.CurrentTask, result);
+                _taskQueue.CurrentTask = null;
                 Stop("Task end");
                 return;
         }
@@ -130,5 +128,5 @@ internal abstract class MiniTaskController<T>
     public abstract void Stop(string label);
 
     public virtual IList<string> GetRemainingTaskNames() =>
-        _taskQueue.Select(x => x.ToString() ?? "?").ToList();
+        _taskQueue.RemainingTasks.Select(x => x.ToString() ?? "?").ToList();
 }
index 95950e14cce9022bd3d7c7658b3a5901053f76a9..bf10142fa68173db091a69ec6a4fe06e8f115b04 100644 (file)
@@ -85,7 +85,7 @@ internal sealed class MovementController : IDisposable
 
     public bool IsPathfinding => _pathfindTask is { IsCompleted: false };
     public DestinationData? Destination { get; set; }
-    public DateTime MovementStartedAt { get; private set; } = DateTime.MaxValue;
+    public DateTime MovementStartedAt { get; private set; } = DateTime.Now;
 
     public void Update()
     {
index 6a0934aafdb39fcf33b2099c96a2eb00829b3fb8..1b0988b99443fc7b38e3eea0b1c68bf0428af002 100644 (file)
@@ -8,7 +8,9 @@ using Dalamud.Game.Gui.Toast;
 using Dalamud.Game.Text.SeStringHandling;
 using Dalamud.Plugin.Services;
 using FFXIVClientStructs.FFXIV.Client.Game;
+using LLib;
 using LLib.GameData;
+using Lumina.Excel.GeneratedSheets;
 using Microsoft.Extensions.Logging;
 using Questionable.Controller.Steps;
 using Questionable.Controller.Steps.Interactions;
@@ -18,6 +20,8 @@ using Questionable.External;
 using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
+using Quest = Questionable.Model.Quest;
+using Mount = Questionable.Controller.Steps.Common.Mount;
 
 namespace Questionable.Controller;
 
@@ -36,6 +40,10 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
     private readonly Configuration _configuration;
     private readonly YesAlreadyIpc _yesAlreadyIpc;
     private readonly TaskCreator _taskCreator;
+    private readonly Mount.Factory _mountFactory;
+    private readonly Combat.Factory _combatFactory;
+
+    private readonly string _actionCanceledText;
 
     private readonly object _progressLock = new();
 
@@ -73,7 +81,10 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
         IToastGui toastGui,
         Configuration configuration,
         YesAlreadyIpc yesAlreadyIpc,
-        TaskCreator taskCreator)
+        TaskCreator taskCreator,
+        Mount.Factory mountFactory,
+        Combat.Factory combatFactory,
+        IDataManager dataManager)
         : base(chatGui, logger)
     {
         _clientState = clientState;
@@ -89,10 +100,14 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
         _configuration = configuration;
         _yesAlreadyIpc = yesAlreadyIpc;
         _taskCreator = taskCreator;
+        _mountFactory = mountFactory;
+        _combatFactory = combatFactory;
 
         _condition.ConditionChange += OnConditionChange;
         _toastGui.Toast += OnNormalToast;
         _toastGui.ErrorToast += OnErrorToast;
+
+        _actionCanceledText = dataManager.GetString<LogMessage>(1314, x => x.Text)!;
     }
 
     public EAutomationType AutomationType
@@ -181,7 +196,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
 
         if (!_clientState.IsLoggedIn || _condition[ConditionFlag.Unconscious])
         {
-            if (_currentTask != null || _taskQueue.Count > 0)
+            if (!_taskQueue.AllTasksComplete)
             {
                 Stop("HP = 0");
                 _movementController.Stop();
@@ -191,7 +206,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
         }
         else if (_configuration.General.UseEscToCancelQuesting && _keyState[VirtualKey.ESCAPE])
         {
-            if (_currentTask != null || _taskQueue.Count > 0)
+            if (!_taskQueue.AllTasksComplete)
             {
                 Stop("ESC pressed");
                 _movementController.Stop();
@@ -204,8 +219,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
             return;
 
         if (AutomationType == EAutomationType.Automatic &&
-            ((_currentTask == null && _taskQueue.Count == 0) ||
-             _currentTask is WaitAtEnd.WaitQuestAccepted)
+            (_taskQueue.AllTasksComplete || _taskQueue.CurrentTask is WaitAtEnd.WaitQuestAccepted)
             && CurrentQuest is { Sequence: 0, Step: 0 } or { Sequence: 0, Step: 255 }
             && DateTime.Now >= CurrentQuest.StepProgress.StartedAt.AddSeconds(15))
         {
@@ -276,8 +290,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
                 questToRun = _nextQuest;
                 currentSequence = _nextQuest.Sequence; // by definition, this should always be 0
                 if (_nextQuest.Step == 0 &&
-                    _currentTask == null &&
-                    _taskQueue.Count == 0 &&
+                    _taskQueue.AllTasksComplete &&
                     AutomationType == EAutomationType.Automatic)
                     ExecuteNextStep();
             }
@@ -286,8 +299,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
                 questToRun = _gatheringQuest;
                 currentSequence = _gatheringQuest.Sequence;
                 if (_gatheringQuest.Step == 0 &&
-                    _currentTask == null &&
-                    _taskQueue.Count == 0 &&
+                    _taskQueue.AllTasksComplete &&
                     AutomationType == EAutomationType.Automatic)
                     ExecuteNextStep();
             }
@@ -392,7 +404,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
             if (questToRun.Step == 255)
             {
                 DebugState = "Step completed";
-                if (_currentTask != null || _taskQueue.Count > 0)
+                if (!_taskQueue.AllTasksComplete)
                     CheckNextTasks("Step complete");
                 return;
             }
@@ -465,10 +477,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
     private void ClearTasksInternal()
     {
         //_logger.LogDebug("Clearing task (internally)");
-        _currentTask = null;
-
-        if (_taskQueue.Count > 0)
-            _taskQueue.Clear();
+        _taskQueue.Reset();
 
         _yesAlreadyIpc.RestoreYesAlready();
         _combatController.Stop("ClearTasksInternal");
@@ -629,13 +638,15 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
 
     public string ToStatString()
     {
-        return _currentTask == null ? $"- (+{_taskQueue.Count})" : $"{_currentTask} (+{_taskQueue.Count})";
+        return _taskQueue.CurrentTask is { } currentTask
+            ? $"{currentTask} (+{_taskQueue.RemainingTasks.Count()})"
+            : $"- (+{_taskQueue.RemainingTasks.Count()})";
     }
 
     public bool HasCurrentTaskMatching<T>([NotNullWhen(true)] out T? task)
         where T : class, ITask
     {
-        if (_currentTask is T t)
+        if (_taskQueue.CurrentTask is T t)
         {
             task = t;
             return true;
@@ -647,7 +658,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
         }
     }
 
-    public bool IsRunning => _currentTask != null || _taskQueue.Count > 0;
+    public bool IsRunning => !_taskQueue.AllTasksComplete;
 
     public sealed class QuestProgress
     {
@@ -687,19 +698,19 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
     {
         lock (_progressLock)
         {
-            if (_currentTask is ISkippableTask)
-                _currentTask = null;
-            else if (_currentTask != null)
+            if (_taskQueue.CurrentTask is ISkippableTask)
+                _taskQueue.CurrentTask = null;
+            else if (_taskQueue.CurrentTask != null)
             {
-                _currentTask = null;
-                while (_taskQueue.Count > 0)
+                _taskQueue.CurrentTask = null;
+                while (_taskQueue.TryPeek(out ITask? task))
                 {
-                    var task = _taskQueue.Dequeue();
+                    _taskQueue.TryDequeue(out _);
                     if (task is ISkippableTask)
                         return;
                 }
 
-                if (_taskQueue.Count == 0)
+                if (_taskQueue.AllTasksComplete)
                 {
                     Stop("Skip");
                     IncreaseStepCount(elementId, currentQuestSequence);
@@ -715,7 +726,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
 
     public void SkipSimulatedTask()
     {
-        _currentTask = null;
+        _taskQueue.CurrentTask = null;
     }
 
     public bool IsInterruptible()
@@ -774,7 +785,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
 
     private void OnConditionChange(ConditionFlag flag, bool value)
     {
-        if (_currentTask is IConditionChangeAware conditionChangeAware)
+        if (_taskQueue.CurrentTask is IConditionChangeAware conditionChangeAware)
             conditionChangeAware.OnConditionChange(flag, value);
     }
 
@@ -785,13 +796,33 @@ internal sealed class QuestController : MiniTaskController<QuestController>, IDi
 
     private void OnErrorToast(ref SeString message, ref bool isHandled)
     {
-        if (_currentTask is IToastAware toastAware)
+        _logger.LogWarning("XXX {A} → {B} XXX", _actionCanceledText, message.TextValue);
+        if (_taskQueue.CurrentTask is IToastAware toastAware)
         {
             if (toastAware.OnErrorToast(message))
             {
                 isHandled = true;
             }
         }
+
+        if (!isHandled)
+        {
+            if (GameFunctions.GameStringEquals(_actionCanceledText, message.TextValue) &&
+                !_condition[ConditionFlag.InFlight])
+                InterruptQueueWithCombat();
+        }
+    }
+
+    public void InterruptQueueWithCombat()
+    {
+        _logger.LogWarning("Interrupted with action canceled message, attempting to resolve");
+        List<ITask> tasks = [];
+        if (_condition[ConditionFlag.Mounted])
+            tasks.Add(_mountFactory.Unmount());
+
+        tasks.Add(_combatFactory.CreateTask(null, false, EEnemySpawnType.QuestInterruption, [], [], []));
+        tasks.Add(new WaitAtEnd.WaitDelay());
+        _taskQueue.InterruptWith(tasks);
     }
 
     public void Dispose()
index 9502455c1c6f924f40d2f491eb6c86517ff2a3c0..958bf2d74bdeb492eb4992a4e2769ff88d005bef 100644 (file)
@@ -46,6 +46,8 @@ internal static class Mount
         private bool _mountTriggered;
         private DateTime _retryAt = DateTime.MinValue;
 
+        public bool ShouldRedoOnInterrupt() => true;
+
         public bool Start()
         {
             if (condition[ConditionFlag.Mounted])
@@ -129,6 +131,8 @@ internal static class Mount
         private bool _unmountTriggered;
         private DateTime _continueAt = DateTime.MinValue;
 
+        public bool ShouldRedoOnInterrupt() => true;
+
         public bool Start()
         {
             if (!condition[ConditionFlag.Mounted])
index 8354406d497dd44b14cd7a02b07af9d959b508e5..0ddc962178e57907a8edef6535e598da1da9a54e 100644 (file)
@@ -5,6 +5,8 @@ namespace Questionable.Controller.Steps;
 
 internal interface ITask
 {
+    bool ShouldRedoOnInterrupt() => false;
+
     bool Start();
 
     ETaskResult Update();
index 38b62286035e07cd65882a8cb459546e04b16d66..170883a3d8c76db7dbff36c3ad56dac6a6f89812 100644 (file)
@@ -101,7 +101,7 @@ internal static class Combat
                 step.CompletionQuestVariablesFlags, step.ComplexCombatData);
         }
 
-        private HandleCombat CreateTask(ElementId elementId, bool isLastStep, EEnemySpawnType enemySpawnType,
+        internal HandleCombat CreateTask(ElementId? elementId, bool isLastStep, EEnemySpawnType enemySpawnType,
             IList<uint> killEnemyDataIds, IList<QuestWorkValue?> completionQuestVariablesFlags,
             IList<ComplexCombatData> complexCombatData)
         {
@@ -115,18 +115,21 @@ internal static class Combat
         }
     }
 
-    private sealed class HandleCombat(
+    internal sealed class HandleCombat(
         bool isLastStep,
         CombatController.CombatData combatData,
         IList<QuestWorkValue?> completionQuestVariableFlags,
         CombatController combatController,
         QuestFunctions questFunctions) : ITask
     {
+        private CombatController.EStatus _status = CombatController.EStatus.NotStarted;
+
         public bool Start() => combatController.Start(combatData);
 
         public ETaskResult Update()
         {
-            if (combatController.Update() != CombatController.EStatus.Complete)
+            _status = combatController.Update();
+            if (_status != CombatController.EStatus.Complete)
                 return ETaskResult.StillRunning;
 
             // if our quest step has any completion flags, we need to check if they are set
@@ -157,11 +160,11 @@ internal static class Combat
         public override string ToString()
         {
             if (QuestWorkUtils.HasCompletionFlags(completionQuestVariableFlags))
-                return "HandleCombat(wait: QW flags)";
+                return $"HandleCombat(wait: QW flags, s: {_status})";
             else if (isLastStep)
-                return "HandleCombat(wait: next sequence)";
+                return $"HandleCombat(wait: next sequence, s: {_status})";
             else
-                return "HandleCombat(wait: not in combat)";
+                return $"HandleCombat(wait: not in combat, s: {_status})";
         }
     }
 }
index cf905a6500e79539137a13265772994ef21e0571..f3318e0eeebf41d24f74451ef9c5934e3301c00e 100644 (file)
@@ -173,6 +173,8 @@ internal static class MoveTo
             _canRestart = moveParams.RestartNavigation;
         }
 
+        public bool ShouldRedoOnInterrupt() => true;
+
         public bool Start()
         {
             float stopDistance = _moveParams.StopDistance ?? QuestStep.DefaultStopDistance;
@@ -313,6 +315,8 @@ internal static class MoveTo
         GameFunctions gameFunctions,
         IClientState clientState) : ITask
     {
+        public bool ShouldRedoOnInterrupt() => true;
+
         public bool Start() => true;
 
         public ETaskResult Update()
@@ -333,6 +337,8 @@ internal static class MoveTo
         private bool _landing;
         private DateTime _continueAt;
 
+        public bool ShouldRedoOnInterrupt() => true;
+
         public bool Start()
         {
             if (!condition[ConditionFlag.InFlight])
diff --git a/Questionable/Controller/Steps/TaskQueue.cs b/Questionable/Controller/Steps/TaskQueue.cs
new file mode 100644 (file)
index 0000000..142413c
--- /dev/null
@@ -0,0 +1,67 @@
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+namespace Questionable.Controller.Steps;
+
+internal sealed class TaskQueue
+{
+    private readonly List<ITask> _tasks = [];
+    private int _currentTaskIndex;
+    public ITask? CurrentTask { get; set; }
+
+    public IEnumerable<ITask> RemainingTasks => _tasks.Skip(_currentTaskIndex);
+    public bool AllTasksComplete => CurrentTask == null && _currentTaskIndex >= _tasks.Count;
+
+    public void Enqueue(ITask task)
+    {
+        _tasks.Add(task);
+    }
+
+    public bool TryDequeue([NotNullWhen(true)] out ITask? task)
+    {
+        if (_currentTaskIndex >= _tasks.Count)
+        {
+            task = null;
+            return false;
+        }
+
+        task = _tasks[_currentTaskIndex];
+        if (task.ShouldRedoOnInterrupt())
+            _currentTaskIndex++;
+        else
+            _tasks.RemoveAt(0);
+        return true;
+    }
+
+    public bool TryPeek([NotNullWhen(true)] out ITask? task)
+    {
+        if (_currentTaskIndex >= _tasks.Count)
+        {
+            task = null;
+            return false;
+        }
+
+        task = _tasks[_currentTaskIndex];
+        return true;
+    }
+
+    public void Reset()
+    {
+        _tasks.Clear();
+        _currentTaskIndex = 0;
+        CurrentTask = null;
+    }
+
+    public void InterruptWith(List<ITask> interruptionTasks)
+    {
+        if (CurrentTask != null)
+        {
+            _tasks.Insert(0, CurrentTask);
+            CurrentTask = null;
+            _currentTaskIndex = 0;
+        }
+
+        _tasks.InsertRange(0, interruptionTasks);
+    }
+}