From d7ca35007e17c2014e5785fcedc93f6ca9591d0b Mon Sep 17 00:00:00 2001 From: Gabriel Deleon Date: Sun, 10 May 2026 19:47:41 +0900 Subject: [PATCH 1/6] Feat: Enable RetryDifficulty --- Questionable/Configuration.cs | 10 ++++++++-- .../Controller/GameUi/InteractionUiController.cs | 2 +- .../SinglePlayerDutyConfigComponent.cs | 12 +++++------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Questionable/Configuration.cs b/Questionable/Configuration.cs index cdb3e9911..127757405 100644 --- a/Questionable/Configuration.cs +++ b/Questionable/Configuration.cs @@ -74,12 +74,18 @@ internal sealed class DutyConfiguration public Dictionary ExpansionHeaderStates { get; set; } = []; } + internal enum EQuestBattleDifficulty : byte + { + Normal = 0, + Easy = 1, + VeryEasy = 2, + } + internal sealed class SinglePlayerDutyConfiguration { public bool RunSoloInstancesWithBossMod { get; set; } - [SuppressMessage("Performance", "CA1822", Justification = "Will be fixed when no longer WIP")] - public byte RetryDifficulty => 0; + public EQuestBattleDifficulty RetryDifficulty { get; set; } = EQuestBattleDifficulty.Normal; public HashSet WhitelistedSinglePlayerDutyCfcIds { get; set; } = []; public HashSet BlacklistedSinglePlayerDutyCfcIds { get; set; } = []; diff --git a/Questionable/Controller/GameUi/InteractionUiController.cs b/Questionable/Controller/GameUi/InteractionUiController.cs index 394e9dbd7..4de1979f4 100644 --- a/Questionable/Controller/GameUi/InteractionUiController.cs +++ b/Questionable/Controller/GameUi/InteractionUiController.cs @@ -843,7 +843,7 @@ private unsafe void DifficultySelectYesNoPostSetup(AtkUnitBase* addonDifficultyS AtkValue* selectChoice = stackalloc AtkValue[] { new() { Type = AtkValueType.Int, Int = 0 }, - new() { Type = AtkValueType.Int, Int = _configuration.SinglePlayerDuties.RetryDifficulty } + new() { Type = AtkValueType.Int, Int = (int)_configuration.SinglePlayerDuties.RetryDifficulty } }; addonDifficultySelectYesNo->FireCallback(2, selectChoice); } diff --git a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs index ce9b0b0ae..ca60b328e 100644 --- a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs +++ b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs @@ -256,23 +256,21 @@ public override void DrawTab() ImGui.TextUnformatted("Work in Progress:"); ImGui.BulletText("Will always use BossMod for combat (ignoring the configured combat module)."); ImGui.BulletText("Only a small subset of quest battles have been tested - most of which are in the MSQ."); - ImGui.BulletText("When retrying a failed battle, it will always start at 'Normal' difficulty."); ImGui.BulletText("Please don't enable this option when using a BossMod fork (such as Reborn);\nwith the missing combat module configuration, it is unlikely to be compatible."); } -#if false using (ImRaii.Disabled(!runSoloInstancesWithBossMod)) { ImGui.Spacing(); - int retryDifficulty = Configuration.SinglePlayerDuties.RetryDifficulty; - if (ImGui.Combo("Difficulty when retrying a quest battle", ref retryDifficulty, _retryDifficulties, - _retryDifficulties.Length)) + int retryDifficulty = (int)Configuration.SinglePlayerDuties.RetryDifficulty; + if (ImGui.Combo("Difficulty when retrying a failed quest battle", ref retryDifficulty, + _retryDifficulties, _retryDifficulties.Length)) { - Configuration.SinglePlayerDuties.RetryDifficulty = (byte)retryDifficulty; + Configuration.SinglePlayerDuties.RetryDifficulty = (EQuestBattleDifficulty)retryDifficulty; Save(); } + ImGuiComponents.HelpMarker("Not all quest battles offer Easy or Very Easy difficulty."); } -#endif } ImGui.Separator(); From b64a7e174af38d2dfbb152ebb91fc6a3381a982b Mon Sep 17 00:00:00 2001 From: Gabriel Deleon Date: Sun, 10 May 2026 20:23:38 +0900 Subject: [PATCH 2/6] wip: SinglePlayerDuty Retry settings --- Questionable/Configuration.cs | 5 +++ .../SinglePlayerDutyConfigComponent.cs | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/Questionable/Configuration.cs b/Questionable/Configuration.cs index 127757405..30d526243 100644 --- a/Questionable/Configuration.cs +++ b/Questionable/Configuration.cs @@ -87,6 +87,11 @@ internal sealed class SinglePlayerDutyConfiguration public EQuestBattleDifficulty RetryDifficulty { get; set; } = EQuestBattleDifficulty.Normal; + /// + /// -1 = retry indefinitely, 0 = do not retry, positive = max retry count. + /// + public int MaxRetries { get; set; } = 0; + public HashSet WhitelistedSinglePlayerDutyCfcIds { get; set; } = []; public HashSet BlacklistedSinglePlayerDutyCfcIds { get; set; } = []; public Dictionary HeaderStates { get; set; } = []; diff --git a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs index ca60b328e..8ca5268df 100644 --- a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs +++ b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs @@ -270,6 +270,49 @@ public override void DrawTab() Save(); } ImGuiComponents.HelpMarker("Not all quest battles offer Easy or Very Easy difficulty."); + + ImGui.Spacing(); + + int maxRetries = Configuration.SinglePlayerDuties.MaxRetries; + + bool doNotRetry = maxRetries == 0; + if (ImGui.Checkbox("Do not retry failed quest battles", ref doNotRetry) && doNotRetry) + { + Configuration.SinglePlayerDuties.MaxRetries = 0; + Save(); + } + + bool retryNTimes = maxRetries > 0; + if (ImGui.Checkbox("Retry##retryNTimes", ref retryNTimes)) + { + Configuration.SinglePlayerDuties.MaxRetries = retryNTimes ? 1 : 0; + Save(); + } + if (retryNTimes) + { + ImGui.SameLine(); + ImGui.SetNextItemWidth(60f); + int n = Math.Max(1, maxRetries); + if (ImGui.InputInt("##retryCount", ref n, 1)) + { + Configuration.SinglePlayerDuties.MaxRetries = Math.Max(1, n); + Save(); + } + ImGui.SameLine(); + ImGui.TextUnformatted("time(s)"); + } + + bool retryIndefinitely = maxRetries == -1; + if (ImGui.Checkbox("Retry indefinitely", ref retryIndefinitely)) + { + Configuration.SinglePlayerDuties.MaxRetries = retryIndefinitely ? -1 : 0; + Save(); + } + if (retryIndefinitely) + { + ImGui.SameLine(); + ImGuiComponents.HelpMarker("Warning: Your gear may break if you retry endlessly!"); + } } } From 4480acccdec0ea2535726d0c11ce0411cce75576 Mon Sep 17 00:00:00 2001 From: Gabriel Deleon Date: Sun, 10 May 2026 20:47:33 +0900 Subject: [PATCH 3/6] Feat: Retry failed Single Player Duty --- .../Steps/Interactions/SinglePlayerDuty.cs | 84 ++++++++++++++++++- Questionable/QuestionablePlugin.cs | 2 + 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs index bfaf822b4..ea2c03b71 100644 --- a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs +++ b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs @@ -10,6 +10,8 @@ using ECommons.UIHelpers.AddonMasterImplementations; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using Questionable.Controller; +using Questionable.Controller.Steps; using Questionable.Controller.Steps.Common; using Questionable.Controller.Steps.Shared; using Questionable.Data; @@ -29,13 +31,29 @@ internal static class SpecialTerritories public const ushort Patisserie = 1298; } + internal sealed class RetryTracker + { + private readonly Dictionary<(ElementId, byte), int> _counts = []; + + public int GetCount(ElementId questId, byte dutyIndex) => + _counts.GetValueOrDefault((questId, dutyIndex)); + + public void Increment(ElementId questId, byte dutyIndex) => + _counts[(questId, dutyIndex)] = GetCount(questId, dutyIndex) + 1; + + public void Reset(ElementId questId, byte dutyIndex) => + _counts.Remove((questId, dutyIndex)); + } + internal sealed class Factory ( BossModIpc bossModIpc, TerritoryData territoryData, IObjectTable objectTable, ICondition condition, - IClientState clientState) : ITaskFactory + IClientState clientState, + QuestFunctions questFunctions, + RetryTracker retryTracker) : ITaskFactory { public IEnumerable CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step) { @@ -61,6 +79,8 @@ public IEnumerable CreateAllTasks(Quest quest, QuestSequence sequence, Qu tId = cfcData.TerritoryId; } + byte sequenceBeforeEntering = questFunctions.GetQuestProgressInfo(quest.Id)?.Sequence ?? 0; + yield return new Mount.UnmountTask(); if (tId == SpecialTerritories.Patisserie) yield return new Commence(cfcId); @@ -107,7 +127,8 @@ public IEnumerable CreateAllTasks(Quest quest, QuestSequence sequence, Qu yield return new WaitSinglePlayerDuty(cfcId); yield return new DisableAi(); - yield return new WaitAtEnd.WaitNextStepOrSequence(); + yield return new CheckSinglePlayerDutyOutcome( + quest, sequence, (byte)sequence.Sequence, step, sequenceBeforeEntering); } } @@ -303,4 +324,63 @@ public override unsafe ETaskResult Update() public override bool ShouldInterruptOnDamage() => false; } + + internal sealed record CheckSinglePlayerDutyOutcome( + Quest Quest, + QuestSequence QuestSequence, + byte SequenceNumber, + QuestStep QuestStep, + byte SequenceBeforeEntering) : ITask + { + public override string ToString() => "CheckSinglePlayerDutyOutcome"; + } + + internal sealed class CheckSinglePlayerDutyOutcomeExecutor + ( + QuestFunctions questFunctions, + QuestController questController, + TaskCreator taskCreator, + RetryTracker retryTracker, + Configuration configuration) : TaskExecutor + { + private DateTime _checkAt = DateTime.MinValue; + + protected override bool Start() + { + _checkAt = DateTime.Now.AddSeconds(2); + return true; + } + + public override ETaskResult Update() + { + if (DateTime.Now < _checkAt) + return ETaskResult.StillRunning; + + QuestProgressInfo? progress = questFunctions.GetQuestProgressInfo(Task.Quest.Id); + byte dutyIndex = Task.QuestStep.SinglePlayerDutyIndex; + + if (progress == null || progress.Sequence > Task.SequenceBeforeEntering) + { + retryTracker.Reset(Task.Quest.Id, dutyIndex); + return ETaskResult.TaskComplete; + } + + int retriesUsed = retryTracker.GetCount(Task.Quest.Id, dutyIndex); + int maxRetries = configuration.SinglePlayerDuties.MaxRetries; + + if (maxRetries == 0 || (maxRetries > 0 && retriesUsed >= maxRetries)) + { + questController.TaskQueue.EnqueueAll([new WaitAtEnd.EndAutomation()]); + return ETaskResult.TaskComplete; + } + + retryTracker.Increment(Task.Quest.Id, dutyIndex); + IReadOnlyList retryTasks = taskCreator.CreateTasks( + Task.Quest, Task.SequenceNumber, Task.QuestSequence, Task.QuestStep); + questController.TaskQueue.EnqueueAll(retryTasks); + return ETaskResult.TaskComplete; + } + + public override bool ShouldInterruptOnDamage() => false; + } } diff --git a/Questionable/QuestionablePlugin.cs b/Questionable/QuestionablePlugin.cs index 316054ad5..aae9d2daf 100644 --- a/Questionable/QuestionablePlugin.cs +++ b/Questionable/QuestionablePlugin.cs @@ -241,6 +241,7 @@ private static void AddTaskFactories(ServiceCollection serviceCollection) .AddTaskFactoryAndExecutor(); + serviceCollection.AddSingleton(); serviceCollection.AddTaskFactory(); serviceCollection .AddTaskExecutor(); @@ -251,6 +252,7 @@ private static void AddTaskFactories(ServiceCollection serviceCollection) .AddTaskExecutor(); serviceCollection.AddTaskExecutor(); serviceCollection.AddTaskExecutor(); + serviceCollection.AddTaskExecutor(); serviceCollection.AddTaskExecutor(); serviceCollection.AddTaskExecutor(); From 1851878bfd38b8b03c1c02abc2f72eaedc101a06 Mon Sep 17 00:00:00 2001 From: Gabriel Deleon Date: Sun, 10 May 2026 21:03:05 +0900 Subject: [PATCH 4/6] wip: build succeeds --- Questionable/Configuration.cs | 2 +- .../Controller/Steps/Interactions/SinglePlayerDuty.cs | 3 +-- .../ConfigComponents/SinglePlayerDutyConfigComponent.cs | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Questionable/Configuration.cs b/Questionable/Configuration.cs index 30d526243..037281a2e 100644 --- a/Questionable/Configuration.cs +++ b/Questionable/Configuration.cs @@ -90,7 +90,7 @@ internal sealed class SinglePlayerDutyConfiguration /// /// -1 = retry indefinitely, 0 = do not retry, positive = max retry count. /// - public int MaxRetries { get; set; } = 0; + public int MaxRetries { get; set; } public HashSet WhitelistedSinglePlayerDutyCfcIds { get; set; } = []; public HashSet BlacklistedSinglePlayerDutyCfcIds { get; set; } = []; diff --git a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs index ea2c03b71..3dd850b73 100644 --- a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs +++ b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs @@ -52,8 +52,7 @@ internal sealed class Factory IObjectTable objectTable, ICondition condition, IClientState clientState, - QuestFunctions questFunctions, - RetryTracker retryTracker) : ITaskFactory + QuestFunctions questFunctions) : ITaskFactory { public IEnumerable CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step) { diff --git a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs index 8ca5268df..82f4d618d 100644 --- a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs +++ b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs @@ -46,9 +46,7 @@ internal sealed class SinglePlayerDutyConfigComponent (Job.BLM, "Magical Ranged Role Quests") ]; -#if false private readonly string[] _retryDifficulties = ["Normal", "Easy", "Very Easy"]; -#endif private readonly TerritoryData _territoryData = territoryData; private readonly QuestRegistry _questRegistry = questRegistry; @@ -266,7 +264,7 @@ public override void DrawTab() if (ImGui.Combo("Difficulty when retrying a failed quest battle", ref retryDifficulty, _retryDifficulties, _retryDifficulties.Length)) { - Configuration.SinglePlayerDuties.RetryDifficulty = (EQuestBattleDifficulty)retryDifficulty; + Configuration.SinglePlayerDuties.RetryDifficulty = (Configuration.EQuestBattleDifficulty)retryDifficulty; Save(); } ImGuiComponents.HelpMarker("Not all quest battles offer Easy or Very Easy difficulty."); From f684742108e870409e0ad4298a97dda4405e6e56 Mon Sep 17 00:00:00 2001 From: Gabriel Deleon Date: Sun, 10 May 2026 22:14:59 +0900 Subject: [PATCH 5/6] wip: add IgnoreDeath task so SinglePlayerDuties don't prematurely end quest automation/retries on death --- Questionable/Controller/QuestController.cs | 5 +++++ Questionable/Controller/Steps/ITask.cs | 1 + .../Controller/Steps/Interactions/SinglePlayerDuty.cs | 9 ++++++++- .../ConfigComponents/SinglePlayerDutyConfigComponent.cs | 2 +- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Questionable/Controller/QuestController.cs b/Questionable/Controller/QuestController.cs index 525cc9162..849720c49 100644 --- a/Questionable/Controller/QuestController.cs +++ b/Questionable/Controller/QuestController.cs @@ -286,6 +286,11 @@ public void Update() { // ignoring death in a dungeon if it is being run by AD } + else if ((_taskQueue.CurrentTaskExecutor?.CurrentTask.IgnoreDeath ?? false) + || _taskQueue.RemainingTasks.Any(t => t.IgnoreDeath)) + { + // a queued task has declared that death should not interrupt automation + } else if (!_taskQueue.AllTasksComplete) StopAllDueToConditionFailed("HP = 0"); } diff --git a/Questionable/Controller/Steps/ITask.cs b/Questionable/Controller/Steps/ITask.cs index 60c22cf93..752f98865 100644 --- a/Questionable/Controller/Steps/ITask.cs +++ b/Questionable/Controller/Steps/ITask.cs @@ -3,4 +3,5 @@ internal interface ITask { bool ShouldRedoOnInterrupt() => false; + bool IgnoreDeath => false; } diff --git a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs index 3dd850b73..b29be96e2 100644 --- a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs +++ b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs @@ -209,6 +209,7 @@ protected override bool Start() internal sealed record WaitSinglePlayerDuty(uint ContentFinderConditionId) : ITask { + public bool IgnoreDeath => true; public override string ToString() => $"Wait(BossMod, left instance {ContentFinderConditionId})"; } @@ -340,7 +341,8 @@ internal sealed class CheckSinglePlayerDutyOutcomeExecutor QuestController questController, TaskCreator taskCreator, RetryTracker retryTracker, - Configuration configuration) : TaskExecutor + Configuration configuration, + IPluginLog pluginLog) : TaskExecutor { private DateTime _checkAt = DateTime.MinValue; @@ -360,6 +362,7 @@ public override ETaskResult Update() if (progress == null || progress.Sequence > Task.SequenceBeforeEntering) { + pluginLog.Information($"[SinglePlayerDuty] Duty succeeded (sequence {Task.SequenceBeforeEntering} -> {progress?.Sequence.ToString() ?? "complete"})"); retryTracker.Reset(Task.Quest.Id, dutyIndex); return ETaskResult.TaskComplete; } @@ -367,13 +370,17 @@ public override ETaskResult Update() int retriesUsed = retryTracker.GetCount(Task.Quest.Id, dutyIndex); int maxRetries = configuration.SinglePlayerDuties.MaxRetries; + pluginLog.Information($"[SinglePlayerDuty] Duty failed (sequence unchanged at {Task.SequenceBeforeEntering}), retries used: {retriesUsed}, max: {maxRetries}"); + if (maxRetries == 0 || (maxRetries > 0 && retriesUsed >= maxRetries)) { + pluginLog.Information("[SinglePlayerDuty] Retry limit reached or retries disabled, stopping automation"); questController.TaskQueue.EnqueueAll([new WaitAtEnd.EndAutomation()]); return ETaskResult.TaskComplete; } retryTracker.Increment(Task.Quest.Id, dutyIndex); + pluginLog.Information($"[SinglePlayerDuty] Retrying duty (attempt {retriesUsed + 1})"); IReadOnlyList retryTasks = taskCreator.CreateTasks( Task.Quest, Task.SequenceNumber, Task.QuestSequence, Task.QuestStep); questController.TaskQueue.EnqueueAll(retryTasks); diff --git a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs index 82f4d618d..5a0673671 100644 --- a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs +++ b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs @@ -289,7 +289,7 @@ public override void DrawTab() if (retryNTimes) { ImGui.SameLine(); - ImGui.SetNextItemWidth(60f); + ImGui.SetNextItemWidth(240f); int n = Math.Max(1, maxRetries); if (ImGui.InputInt("##retryCount", ref n, 1)) { From 76a34e3c212a5ca59b0c6fdd2a91223b47e1abd3 Mon Sep 17 00:00:00 2001 From: Gabriel Deleon Date: Sun, 10 May 2026 23:12:50 +0900 Subject: [PATCH 6/6] wip: adds check for quest variables --- .../Steps/Interactions/SinglePlayerDuty.cs | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs index b29be96e2..99574d3d0 100644 --- a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs +++ b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs @@ -12,6 +12,7 @@ using FFXIVClientStructs.FFXIV.Client.UI.Misc; using Questionable.Controller; using Questionable.Controller.Steps; +using Questionable.Controller.Utils; using Questionable.Controller.Steps.Common; using Questionable.Controller.Steps.Shared; using Questionable.Data; @@ -78,7 +79,9 @@ public IEnumerable CreateAllTasks(Quest quest, QuestSequence sequence, Qu tId = cfcData.TerritoryId; } - byte sequenceBeforeEntering = questFunctions.GetQuestProgressInfo(quest.Id)?.Sequence ?? 0; + QuestProgressInfo? progressBeforeEntering = questFunctions.GetQuestProgressInfo(quest.Id); + byte sequenceBeforeEntering = progressBeforeEntering?.Sequence ?? 0; + IReadOnlyList? variablesBeforeEntering = progressBeforeEntering?.Variables.ToArray(); yield return new Mount.UnmountTask(); if (tId == SpecialTerritories.Patisserie) @@ -127,7 +130,7 @@ public IEnumerable CreateAllTasks(Quest quest, QuestSequence sequence, Qu yield return new WaitSinglePlayerDuty(cfcId); yield return new DisableAi(); yield return new CheckSinglePlayerDutyOutcome( - quest, sequence, (byte)sequence.Sequence, step, sequenceBeforeEntering); + quest, sequence, (byte)sequence.Sequence, step, sequenceBeforeEntering, variablesBeforeEntering); } } @@ -330,7 +333,8 @@ internal sealed record CheckSinglePlayerDutyOutcome( QuestSequence QuestSequence, byte SequenceNumber, QuestStep QuestStep, - byte SequenceBeforeEntering) : ITask + byte SequenceBeforeEntering, + IReadOnlyList? VariablesBeforeEntering) : ITask { public override string ToString() => "CheckSinglePlayerDutyOutcome"; } @@ -360,9 +364,34 @@ public override ETaskResult Update() QuestProgressInfo? progress = questFunctions.GetQuestProgressInfo(Task.Quest.Id); byte dutyIndex = Task.QuestStep.SinglePlayerDutyIndex; - if (progress == null || progress.Sequence > Task.SequenceBeforeEntering) + if (progress == null) { - pluginLog.Information($"[SinglePlayerDuty] Duty succeeded (sequence {Task.SequenceBeforeEntering} -> {progress?.Sequence.ToString() ?? "complete"})"); + pluginLog.Information("[SinglePlayerDuty] Duty succeeded (quest completed)"); + retryTracker.Reset(Task.Quest.Id, dutyIndex); + return ETaskResult.TaskComplete; + } + + if (progress.Sequence > Task.SequenceBeforeEntering) + { + pluginLog.Information($"[SinglePlayerDuty] Duty succeeded (sequence {Task.SequenceBeforeEntering} -> {progress.Sequence})"); + retryTracker.Reset(Task.Quest.Id, dutyIndex); + return ETaskResult.TaskComplete; + } + + bool hasCompletionFlags = QuestWorkUtils.HasCompletionFlags(Task.QuestStep.CompletionQuestVariablesFlags); + if (hasCompletionFlags) + { + if (QuestWorkUtils.MatchesQuestWork(Task.QuestStep.CompletionQuestVariablesFlags, progress)) + { + pluginLog.Information("[SinglePlayerDuty] Duty succeeded (completion flags matched)"); + retryTracker.Reset(Task.Quest.Id, dutyIndex); + return ETaskResult.TaskComplete; + } + } + else if (Task.VariablesBeforeEntering != null && + !progress.Variables.SequenceEqual(Task.VariablesBeforeEntering)) + { + pluginLog.Information("[SinglePlayerDuty] Duty succeeded (quest variables changed)"); retryTracker.Reset(Task.Quest.Id, dutyIndex); return ETaskResult.TaskComplete; }