Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions Questionable/Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,23 @@ internal sealed class DutyConfiguration
public Dictionary<string, bool> 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;

/// <summary>
/// -1 = retry indefinitely, 0 = do not retry, positive = max retry count.
/// </summary>
public int MaxRetries { get; set; }

public HashSet<uint> WhitelistedSinglePlayerDutyCfcIds { get; set; } = [];
public HashSet<uint> BlacklistedSinglePlayerDutyCfcIds { get; set; } = [];
Expand Down
2 changes: 1 addition & 1 deletion Questionable/Controller/GameUi/InteractionUiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
5 changes: 5 additions & 0 deletions Questionable/Controller/QuestController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
1 change: 1 addition & 0 deletions Questionable/Controller/Steps/ITask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
internal interface ITask
{
bool ShouldRedoOnInterrupt() => false;
bool IgnoreDeath => false;
}
119 changes: 117 additions & 2 deletions Questionable/Controller/Steps/Interactions/SinglePlayerDuty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
{
Expand All @@ -61,6 +79,10 @@ public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, Qu
tId = cfcData.TerritoryId;
}

QuestProgressInfo? progressBeforeEntering = questFunctions.GetQuestProgressInfo(quest.Id);
byte sequenceBeforeEntering = progressBeforeEntering?.Sequence ?? 0;
IReadOnlyList<byte>? variablesBeforeEntering = progressBeforeEntering?.Variables.ToArray();

yield return new Mount.UnmountTask();
if (tId == SpecialTerritories.Patisserie)
yield return new Commence(cfcId);
Expand Down Expand Up @@ -107,7 +129,8 @@ public IEnumerable<ITask> 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);
}
}

Expand Down Expand Up @@ -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})";
}

Expand Down Expand Up @@ -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<byte>? VariablesBeforeEntering) : ITask
{
public override string ToString() => "CheckSinglePlayerDutyOutcome";
}

internal sealed class CheckSinglePlayerDutyOutcomeExecutor
(
QuestFunctions questFunctions,
QuestController questController,
TaskCreator taskCreator,
RetryTracker retryTracker,
Configuration configuration,
IPluginLog pluginLog) : TaskExecutor<CheckSinglePlayerDutyOutcome>
{
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<ITask> retryTasks = taskCreator.CreateTasks(
Task.Quest, Task.SequenceNumber, Task.QuestSequence, Task.QuestStep);
questController.TaskQueue.EnqueueAll(retryTasks);
return ETaskResult.TaskComplete;
}

public override bool ShouldInterruptOnDamage() => false;
}
}
2 changes: 2 additions & 0 deletions Questionable/QuestionablePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ private static void AddTaskFactories(ServiceCollection serviceCollection)
.AddTaskFactoryAndExecutor<TurnInDelivery.Task, TurnInDelivery.Factory,
TurnInDelivery.SatisfactionSupplyTurnIn>();

serviceCollection.AddSingleton<SinglePlayerDuty.RetryTracker>();
serviceCollection.AddTaskFactory<SinglePlayerDuty.Factory>();
serviceCollection
.AddTaskExecutor<SinglePlayerDuty.StartSinglePlayerDuty, SinglePlayerDuty.StartSinglePlayerDutyExecutor>();
Expand All @@ -251,6 +252,7 @@ private static void AddTaskFactories(ServiceCollection serviceCollection)
.AddTaskExecutor<SinglePlayerDuty.WaitSinglePlayerDuty, SinglePlayerDuty.WaitSinglePlayerDutyExecutor>();
serviceCollection.AddTaskExecutor<SinglePlayerDuty.DisableAi, SinglePlayerDuty.DisableAiExecutor>();
serviceCollection.AddTaskExecutor<SinglePlayerDuty.SetTarget, SinglePlayerDuty.SetTargetExecutor>();
serviceCollection.AddTaskExecutor<SinglePlayerDuty.CheckSinglePlayerDutyOutcome, SinglePlayerDuty.CheckSinglePlayerDutyOutcomeExecutor>();

serviceCollection.AddTaskExecutor<WaitCondition.Task, WaitCondition.WaitConditionExecutor>();
serviceCollection.AddTaskExecutor<WaitNavmesh.Task, WaitNavmesh.Executor>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down