From 822b4cdf84c21e8fdfaf48028a692461a3ebe9f0 Mon Sep 17 00:00:00 2001 From: Frantastic Date: Mon, 13 Jul 2026 15:19:25 -0400 Subject: [PATCH 1/3] Add cooperative quest execution pause requests Questionable now finishes the current task and pauses before dequeuing the next one. Named IPC requests preserve quest and task state while allowing callers to release only their own pause. --- Questionable/Controller/QuestController.cs | 24 ++++++++++++++++++++++ Questionable/External/QuestionableIpc.cs | 11 ++++++++++ 2 files changed, 35 insertions(+) diff --git a/Questionable/Controller/QuestController.cs b/Questionable/Controller/QuestController.cs index 951e1dc3f..f8b83c6b6 100644 --- a/Questionable/Controller/QuestController.cs +++ b/Questionable/Controller/QuestController.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Collections.Generic; using System.Numerics; using System.Threading; using Dalamud.Game.ClientState.Conditions; @@ -108,6 +109,8 @@ public enum ECurrentQuestType private readonly IToastGui _toastGui; private readonly ICommandManager _commandManager; private EAutomationType _automationType; + private readonly HashSet _pauseRequests = new(StringComparer.Ordinal); + private bool _pauseEffective; private DateTime _lastAutoRefresh = DateTime.MinValue; /// True while recovering from the player's death (waiting for the return prompt / respawn). @@ -265,6 +268,20 @@ public QuestProgress? PendingQuest public Func? IsQuestWindowOpenFunction { private get; set; } = () => true; public bool IsRunning => !_taskQueue.AllTasksComplete; + public void SetPauseRequest(string owner, bool paused) + { + if (string.IsNullOrWhiteSpace(owner)) + throw new ArgumentException("A pause-request owner is required.", nameof(owner)); + if (paused) + _pauseRequests.Add(owner); + else + _pauseRequests.Remove(owner); + if (_pauseRequests.Count == 0) + _pauseEffective = false; + } + + public bool IsPauseRequestEffective(string owner) => + !string.IsNullOrWhiteSpace(owner) && _pauseRequests.Contains(owner) && _pauseEffective; public TaskQueue TaskQueue => _taskQueue; public string? CurrentTaskState @@ -340,6 +357,13 @@ public void Update() if (IsQuestingStopped) return; + if (_pauseRequests.Count > 0 && _taskQueue.CurrentTaskExecutor == null) + { + _pauseEffective = true; + return; + } + _pauseEffective = false; + UpdateCurrentQuest(); if (!_clientState.IsLoggedIn) diff --git a/Questionable/External/QuestionableIpc.cs b/Questionable/External/QuestionableIpc.cs index 38af5ae18..5590def8b 100644 --- a/Questionable/External/QuestionableIpc.cs +++ b/Questionable/External/QuestionableIpc.cs @@ -38,6 +38,8 @@ internal sealed class QuestionableIpc : IDisposable private const string IpcStartGathering = "Questionable.StartGathering"; private const string IpcStartGatheringComplex = "Questionable.StartGatheringComplex"; private const string IpcStop = "Questionable.Stop"; + private const string IpcSetPauseRequest = "Questionable.SetPauseRequest"; + private const string IpcIsPauseRequestEffective = "Questionable.IsPauseRequestEffective"; private const string IpcRedoLookup = "Questionable.RedoLookup"; private const string IpcRedoLookupIndex = "Questionable.RedoLookupIndex"; private readonly ICallGateProvider _addQuestPriority; @@ -69,6 +71,8 @@ internal sealed class QuestionableIpc : IDisposable private readonly ICallGateProvider _startQuest; private readonly ICallGateProvider _startSingleQuest; private readonly ICallGateProvider _stop; + private readonly ICallGateProvider _setPauseRequest; + private readonly ICallGateProvider _isPauseRequestEffective; public QuestionableIpc( QuestController questController, @@ -148,6 +152,11 @@ public QuestionableIpc( _stop = pluginInterface.GetIpcProvider(IpcStop); _stop.RegisterFunc(Stop); + _setPauseRequest = pluginInterface.GetIpcProvider(IpcSetPauseRequest); + _setPauseRequest.RegisterAction(questController.SetPauseRequest); + _isPauseRequestEffective = pluginInterface.GetIpcProvider(IpcIsPauseRequestEffective); + _isPauseRequestEffective.RegisterFunc(questController.IsPauseRequestEffective); + _redoUtil = redoUtil; _redoLookup = pluginInterface.GetIpcProvider(IpcRedoLookup); @@ -178,6 +187,8 @@ public void Dispose() _startGathering.UnregisterFunc(); _startGatheringComplex.UnregisterFunc(); _stop.UnregisterFunc(); + _setPauseRequest.UnregisterAction(); + _isPauseRequestEffective.UnregisterFunc(); _redoLookup.UnregisterFunc(); } From e8dd5acfcdc40bc9c321691093de9d4e4ee8595c Mon Sep 17 00:00:00 2001 From: Frantastic Date: Mon, 13 Jul 2026 15:47:23 -0400 Subject: [PATCH 2/3] Synchronize cooperative quest pause ownership Protect named pause requests from cross-thread IPC access while retaining the existing safe boundary between task executors. Removing the final request resumes the preserved queue on the next framework update. --- Questionable/Controller/QuestController.cs | 39 ++++++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/Questionable/Controller/QuestController.cs b/Questionable/Controller/QuestController.cs index f8b83c6b6..34026395c 100644 --- a/Questionable/Controller/QuestController.cs +++ b/Questionable/Controller/QuestController.cs @@ -109,6 +109,7 @@ public enum ECurrentQuestType private readonly IToastGui _toastGui; private readonly ICommandManager _commandManager; private EAutomationType _automationType; + private readonly object _pauseRequestGate = new(); private readonly HashSet _pauseRequests = new(StringComparer.Ordinal); private bool _pauseEffective; private DateTime _lastAutoRefresh = DateTime.MinValue; @@ -272,16 +273,32 @@ public void SetPauseRequest(string owner, bool paused) { if (string.IsNullOrWhiteSpace(owner)) throw new ArgumentException("A pause-request owner is required.", nameof(owner)); - if (paused) - _pauseRequests.Add(owner); - else - _pauseRequests.Remove(owner); - if (_pauseRequests.Count == 0) - _pauseEffective = false; + lock (_pauseRequestGate) + { + if (paused) + _pauseRequests.Add(owner); + else + _pauseRequests.Remove(owner); + } + } + + public bool IsPauseRequestEffective(string owner) + { + lock (_pauseRequestGate) + return !string.IsNullOrWhiteSpace(owner) && _pauseRequests.Contains(owner) && _pauseEffective; + } + + private bool HasPauseRequests() + { + lock (_pauseRequestGate) + return _pauseRequests.Count > 0; } - public bool IsPauseRequestEffective(string owner) => - !string.IsNullOrWhiteSpace(owner) && _pauseRequests.Contains(owner) && _pauseEffective; + private void SetPauseEffective(bool effective) + { + lock (_pauseRequestGate) + _pauseEffective = effective; + } public TaskQueue TaskQueue => _taskQueue; public string? CurrentTaskState @@ -357,12 +374,12 @@ public void Update() if (IsQuestingStopped) return; - if (_pauseRequests.Count > 0 && _taskQueue.CurrentTaskExecutor == null) + if (HasPauseRequests() && _taskQueue.CurrentTaskExecutor == null) { - _pauseEffective = true; + SetPauseEffective(true); return; } - _pauseEffective = false; + SetPauseEffective(false); UpdateCurrentQuest(); From 062fbcaf4c5cf8fd6b0c29677361730309652315 Mon Sep 17 00:00:00 2001 From: Frantastic Date: Mon, 13 Jul 2026 15:50:14 -0400 Subject: [PATCH 3/3] Expire abandoned quest pause leases Renew named pause requests for ten minutes and prune expired owners before task-boundary decisions. Questionable resumes a preserved queue after a consumer crash instead of remaining paused forever. --- Questionable/Controller/QuestController.cs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Questionable/Controller/QuestController.cs b/Questionable/Controller/QuestController.cs index 34026395c..2cc2b07aa 100644 --- a/Questionable/Controller/QuestController.cs +++ b/Questionable/Controller/QuestController.cs @@ -109,8 +109,9 @@ public enum ECurrentQuestType private readonly IToastGui _toastGui; private readonly ICommandManager _commandManager; private EAutomationType _automationType; + private static readonly TimeSpan PauseRequestLifetime = TimeSpan.FromMinutes(10); private readonly object _pauseRequestGate = new(); - private readonly HashSet _pauseRequests = new(StringComparer.Ordinal); + private readonly Dictionary _pauseRequests = new(StringComparer.Ordinal); private bool _pauseEffective; private DateTime _lastAutoRefresh = DateTime.MinValue; @@ -276,7 +277,7 @@ public void SetPauseRequest(string owner, bool paused) lock (_pauseRequestGate) { if (paused) - _pauseRequests.Add(owner); + _pauseRequests[owner] = DateTime.UtcNow.Add(PauseRequestLifetime); else _pauseRequests.Remove(owner); } @@ -285,13 +286,26 @@ public void SetPauseRequest(string owner, bool paused) public bool IsPauseRequestEffective(string owner) { lock (_pauseRequestGate) - return !string.IsNullOrWhiteSpace(owner) && _pauseRequests.Contains(owner) && _pauseEffective; + { + PruneExpiredPauseRequests(); + return !string.IsNullOrWhiteSpace(owner) && _pauseRequests.ContainsKey(owner) && _pauseEffective; + } } private bool HasPauseRequests() { lock (_pauseRequestGate) + { + PruneExpiredPauseRequests(); return _pauseRequests.Count > 0; + } + } + + private void PruneExpiredPauseRequests() + { + var now = DateTime.UtcNow; + foreach (var owner in _pauseRequests.Where(request => request.Value <= now).Select(request => request.Key).ToArray()) + _pauseRequests.Remove(owner); } private void SetPauseEffective(bool effective)