diff --git a/Questionable/Configuration.cs b/Questionable/Configuration.cs index cdb3e9911..037281a2e 100644 --- a/Questionable/Configuration.cs +++ b/Questionable/Configuration.cs @@ -74,12 +74,23 @@ 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; + + /// + /// -1 = retry indefinitely, 0 = do not retry, positive = max retry count. + /// + public int MaxRetries { get; set; } 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/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 bfaf822b4..99574d3d0 100644 --- a/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs +++ b/Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs @@ -10,6 +10,9 @@ 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.Utils; using Questionable.Controller.Steps.Common; using Questionable.Controller.Steps.Shared; using Questionable.Data; @@ -29,13 +32,28 @@ 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) : ITaskFactory { public IEnumerable CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step) { @@ -61,6 +79,10 @@ public IEnumerable CreateAllTasks(Quest quest, QuestSequence sequence, Qu tId = cfcData.TerritoryId; } + 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) yield return new Commence(cfcId); @@ -107,7 +129,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, variablesBeforeEntering); } } @@ -189,6 +212,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})"; } @@ -303,4 +327,95 @@ public override unsafe ETaskResult Update() public override bool ShouldInterruptOnDamage() => false; } + + internal sealed record CheckSinglePlayerDutyOutcome( + Quest Quest, + QuestSequence QuestSequence, + byte SequenceNumber, + QuestStep QuestStep, + byte SequenceBeforeEntering, + IReadOnlyList? VariablesBeforeEntering) : ITask + { + public override string ToString() => "CheckSinglePlayerDutyOutcome"; + } + + internal sealed class CheckSinglePlayerDutyOutcomeExecutor + ( + QuestFunctions questFunctions, + QuestController questController, + TaskCreator taskCreator, + RetryTracker retryTracker, + Configuration configuration, + IPluginLog pluginLog) : 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) + { + 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; + } + + 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); + 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(); diff --git a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs index ce9b0b0ae..5a0673671 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; @@ -256,23 +254,64 @@ 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 = (Configuration.EQuestBattleDifficulty)retryDifficulty; 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(240f); + 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!"); + } } -#endif } ImGui.Separator();