Add UI to enable/disable quest battles
authorLiza Carvelli <liza@carvel.li>
Fri, 21 Feb 2025 01:17:47 +0000 (02:17 +0100)
committerLiza Carvelli <liza@carvel.li>
Fri, 21 Feb 2025 01:19:01 +0000 (02:19 +0100)
20 files changed:
LLib
QuestPaths/4.x - Stormblood/Class Quests/DRG/2914_Dragon Sound.json
QuestPaths/4.x - Stormblood/Class Quests/WAR/2900_Curious Gorge Meets His Match.json
Questionable/Configuration.cs
Questionable/Controller/QuestController.cs
Questionable/Controller/QuestRegistry.cs
Questionable/Data/QuestData.cs
Questionable/Data/TerritoryData.cs
Questionable/External/BossModIpc.cs
Questionable/QuestionablePlugin.cs
Questionable/Validation/EIssueType.cs
Questionable/Validation/Validators/SinglePlayerInstanceValidator.cs [new file with mode: 0644]
Questionable/Windows/ConfigComponents/ConfigComponent.cs
Questionable/Windows/ConfigComponents/DebugConfigComponent.cs
Questionable/Windows/ConfigComponents/DutyConfigComponent.cs
Questionable/Windows/ConfigComponents/GeneralConfigComponent.cs
Questionable/Windows/ConfigComponents/NotificationConfigComponent.cs
Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs [new file with mode: 0644]
Questionable/Windows/ConfigWindow.cs
vendor/pictomancy

diff --git a/LLib b/LLib
index 746d14681baa91132784ab17f8f49671e86ea211..edab3c7ecc6bd66ac07e3c3938eb9c8a835a1c42 160000 (submodule)
--- a/LLib
+++ b/LLib
@@ -1 +1 @@
-Subproject commit 746d14681baa91132784ab17f8f49671e86ea211
+Subproject commit edab3c7ecc6bd66ac07e3c3938eb9c8a835a1c42
index 3561d3c0597d77a50c128ad722938da0b81df48b..fe27abf7a1dc1540d08049f0210c771f6e000e39 100644 (file)
@@ -34,7 +34,7 @@
             "Z": -509.51404
           },
           "TerritoryId": 622,
-          "InteractionType": "Interact",
+          "InteractionType": "SinglePlayerDuty",
           "Fly": true
         }
       ]
index d17c860b1419ec4c56761b3787d0d8289c15485b..12c31df17e4fa1f6c12faedf3a78b86c24e9b074 100644 (file)
@@ -35,7 +35,7 @@
             "Z": 686.427
           },
           "TerritoryId": 135,
-          "InteractionType": "Interact",
+          "InteractionType": "SinglePlayerDuty",
           "AetheryteShortcut": "Lower La Noscea - Moraby Drydocks"
         }
       ]
index 74bb05e6ef19743f7088898ae89e4b6dd4bb7bb4..a4126ed6f91508f84eb9b8dd3ed6cc2766fbd07c 100644 (file)
@@ -14,7 +14,7 @@ internal sealed class Configuration : IPluginConfiguration
     public int PluginSetupCompleteVersion { get; set; }
     public GeneralConfiguration General { get; } = new();
     public DutyConfiguration Duties { get; } = new();
-    public SoloDutyConfiguration SoloDuties { get; } = new();
+    public SinglePlayerDutyConfiguration SinglePlayerDuties { get; } = new();
     public NotificationConfiguration Notifications { get; } = new();
     public AdvancedConfiguration Advanced { get; } = new();
     public WindowConfig DebugWindowConfig { get; } = new();
@@ -42,11 +42,11 @@ internal sealed class Configuration : IPluginConfiguration
         public HashSet<uint> BlacklistedDutyCfcIds { get; set; } = [];
     }
 
-    internal sealed class SoloDutyConfiguration
+    internal sealed class SinglePlayerDutyConfiguration
     {
         public bool RunSoloInstancesWithBossMod { get; set; }
-        public HashSet<uint> WhitelistedSoloDutyCfcIds { get; set; } = [];
-        public HashSet<uint> BlacklistedSoloDutyCfcIds { get; set; } = [];
+        public HashSet<uint> WhitelistedSinglePlayerDutyCfcIds { get; set; } = [];
+        public HashSet<uint> BlacklistedSinglePlayerDutyCfcIds { get; set; } = [];
     }
 
     internal sealed class NotificationConfiguration
index ce92099eba31b8aff1c2f58c33806526a41d1ad1..9fbaadfdd415fc7e6409a51cd4ea5a14ea2c9a6f 100644 (file)
@@ -15,6 +15,7 @@ using Questionable.External;
 using Questionable.Functions;
 using Questionable.Model;
 using Questionable.Model.Questing;
+using Questionable.Windows.ConfigComponents;
 using Quest = Questionable.Model.Quest;
 
 namespace Questionable.Controller;
@@ -35,6 +36,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
     private readonly Configuration _configuration;
     private readonly YesAlreadyIpc _yesAlreadyIpc;
     private readonly TaskCreator _taskCreator;
+    private readonly SinglePlayerDutyConfigComponent _singlePlayerDutyConfigComponent;
     private readonly ILogger<QuestController> _logger;
 
     private readonly object _progressLock = new();
@@ -76,7 +78,8 @@ internal sealed class QuestController : MiniTaskController<QuestController>
         TaskCreator taskCreator,
         IServiceProvider serviceProvider,
         InterruptHandler interruptHandler,
-        IDataManager dataManager)
+        IDataManager dataManager,
+        SinglePlayerDutyConfigComponent singlePlayerDutyConfigComponent)
         : base(chatGui, condition, serviceProvider, interruptHandler, dataManager, logger)
     {
         _clientState = clientState;
@@ -93,6 +96,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
         _configuration = configuration;
         _yesAlreadyIpc = yesAlreadyIpc;
         _taskCreator = taskCreator;
+        _singlePlayerDutyConfigComponent = singlePlayerDutyConfigComponent;
         _logger = logger;
 
         _condition.ConditionChange += OnConditionChange;
@@ -169,6 +173,7 @@ internal sealed class QuestController : MiniTaskController<QuestController>
             DebugState = null;
 
             _questRegistry.Reload();
+            _singlePlayerDutyConfigComponent.Reload();
         }
     }
 
index 5e948761dafde60c75475c37569eafd6b4970607..0e066c96b35faa6f80710d2d517b4c7327e1869a 100644 (file)
@@ -240,11 +240,11 @@ internal sealed class QuestRegistry
     public bool TryGetQuest(ElementId questId, [NotNullWhen(true)] out Quest? quest)
         => _quests.TryGetValue(questId, out quest);
 
-    public List<QuestInfo> GetKnownClassJobQuests(EClassJob classJob)
+    public List<QuestInfo> GetKnownClassJobQuests(EClassJob classJob, bool includeRoleQuests = true)
     {
-        List<QuestInfo> allQuests = [.._questData.GetClassJobQuests(classJob)];
+        List<QuestInfo> allQuests = [.._questData.GetClassJobQuests(classJob, includeRoleQuests)];
         if (classJob.AsJob() != classJob)
-            allQuests.AddRange(_questData.GetClassJobQuests(classJob.AsJob()));
+            allQuests.AddRange(_questData.GetClassJobQuests(classJob.AsJob(), includeRoleQuests));
 
         return allQuests
             .Where(x => IsKnownQuest(x.QuestId))
index f84edb9599cb731d6b615e1fbfba20f2401be6ef..e3cc60f19ea955b0b9f630237cb3fcd49b2906f4 100644 (file)
@@ -247,8 +247,8 @@ internal sealed class QuestData
 
     private void AddPreviousQuest(QuestId questToUpdate, QuestId requiredQuestId)
     {
-        QuestInfo quest = (QuestInfo)_quests[questToUpdate];
-        quest.AddPreviousQuest(new PreviousQuestInfo(requiredQuestId));
+        if (_quests.TryGetValue(questToUpdate, out IQuestInfo? quest) && quest is QuestInfo questInfo)
+            questInfo.AddPreviousQuest(new PreviousQuestInfo(requiredQuestId));
     }
 
     private void AddGcFollowUpQuests()
@@ -300,7 +300,7 @@ internal sealed class QuestData
             .ToList();
     }
 
-    public List<QuestInfo> GetClassJobQuests(EClassJob classJob)
+    public List<QuestInfo> GetClassJobQuests(EClassJob classJob, bool includeRoleQuests = false)
     {
         List<uint> chapterIds = classJob switch
         {
@@ -367,7 +367,20 @@ internal sealed class QuestData
             _ => throw new ArgumentOutOfRangeException(nameof(classJob)),
         };
 
-        chapterIds.AddRange(classJob switch
+        if (includeRoleQuests)
+        {
+            chapterIds.AddRange(GetRoleQuestIds(classJob));
+        }
+
+        return GetQuestsInNewGamePlusChapters(chapterIds);
+    }
+
+    public List<QuestInfo> GetRoleQuests(EClassJob referenceClassJob) =>
+        GetQuestsInNewGamePlusChapters(GetRoleQuestIds(referenceClassJob).ToList());
+
+    private static IEnumerable<uint> GetRoleQuestIds(EClassJob classJob)
+    {
+        return classJob switch
         {
             _ when classJob.IsTank() => TankRoleQuests,
             _ when classJob.IsHealer() => HealerRoleQuests,
@@ -375,9 +388,7 @@ internal sealed class QuestData
             _ when classJob.IsPhysicalRanged() => PhysicalRangedRoleQuests,
             _ when classJob.IsCaster() && classJob != EClassJob.BlueMage => CasterRoleQuests,
             _ => []
-        });
-
-        return GetQuestsInNewGamePlusChapters(chapterIds);
+        };
     }
 
     private List<QuestInfo> GetQuestsInNewGamePlusChapters(List<uint> chapterIds)
index c57ada43a09d5b9ff1d4a11b8d77dba48a5d85c8..f35f9cee7e4db1ac4f65af6ab6e98a56ce5f44ad 100644 (file)
@@ -99,6 +99,11 @@ internal sealed class TerritoryData
         }
     }
 
+    public IEnumerable<(ElementId QuestId, byte Index, ContentFinderConditionData Data)> GetAllQuestsWithQuestBattles()
+    {
+        return _questsToCfc.Select(x => (x.Key.QuestId, x.Key.Index, _contentFinderConditions[x.Value]));
+    }
+
     private static string FixName(string name, ClientLanguage language)
     {
         if (string.IsNullOrEmpty(name) || language != ClientLanguage.English)
index 82a3de68ddad3d33995b64a7ea033609ff9eec63..939a35d7ae3135ed188b40c9d02013a7affb0ddf 100644 (file)
@@ -84,16 +84,16 @@ internal sealed class BossModIpc
 
     public bool IsConfiguredToRunSoloInstance(ElementId questId, byte dutyIndex, bool enabledByDefault)
     {
-        if (!_configuration.SoloDuties.RunSoloInstancesWithBossMod)
+        if (!_configuration.SinglePlayerDuties.RunSoloInstancesWithBossMod)
             return false;
 
         if (!_territoryData.TryGetContentFinderConditionForSoloInstance(questId, dutyIndex, out var cfcData))
             return false;
 
-        if (_configuration.SoloDuties.BlacklistedSoloDutyCfcIds.Contains(cfcData.ContentFinderConditionId))
+        if (_configuration.SinglePlayerDuties.BlacklistedSinglePlayerDutyCfcIds.Contains(cfcData.ContentFinderConditionId))
             return false;
 
-        if (_configuration.SoloDuties.WhitelistedSoloDutyCfcIds.Contains(cfcData.ContentFinderConditionId))
+        if (_configuration.SinglePlayerDuties.WhitelistedSinglePlayerDutyCfcIds.Contains(cfcData.ContentFinderConditionId))
             return true;
 
         return enabledByDefault;
index 0f776eb62e798de585ccde41becd9a65d981d004..e6a82af2ded543ecf7f7efb378af485879d9c62d 100644 (file)
@@ -302,6 +302,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
 
         serviceCollection.AddSingleton<GeneralConfigComponent>();
         serviceCollection.AddSingleton<DutyConfigComponent>();
+        serviceCollection.AddSingleton<SinglePlayerDutyConfigComponent>();
         serviceCollection.AddSingleton<NotificationConfigComponent>();
         serviceCollection.AddSingleton<DebugConfigComponent>();
     }
@@ -317,6 +318,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddSingleton<IQuestValidator, AethernetShortcutValidator>();
         serviceCollection.AddSingleton<IQuestValidator, DialogueChoiceValidator>();
         serviceCollection.AddSingleton<IQuestValidator, ClassQuestShouldHaveShortcutValidator>();
+        serviceCollection.AddSingleton<IQuestValidator, SinglePlayerInstanceValidator>();
         serviceCollection.AddSingleton<IQuestValidator, UniqueSinglePlayerInstanceValidator>();
         serviceCollection.AddSingleton<JsonSchemaValidator>();
         serviceCollection.AddSingleton<IQuestValidator>(sp => sp.GetRequiredService<JsonSchemaValidator>());
@@ -326,6 +328,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
     {
         serviceProvider.GetRequiredService<QuestRegistry>().Reload();
         serviceProvider.GetRequiredService<GatheringPointRegistry>().Reload();
+        serviceProvider.GetRequiredService<SinglePlayerDutyConfigComponent>().Reload();
         serviceProvider.GetRequiredService<CommandHandler>();
         serviceProvider.GetRequiredService<ContextMenuController>();
         serviceProvider.GetRequiredService<CraftworksSupplyController>();
index 4d41f9970e9752ce08944c78fbe1648a0409902f..3f725738a8ac578c248b171b8a036323843d4b1f 100644 (file)
@@ -19,4 +19,5 @@ public enum EIssueType
     InvalidExcelRef,
     ClassQuestWithoutAetheryteShortcut,
     DuplicateSinglePlayerInstance,
+    UnusedSinglePlayerInstance,
 }
diff --git a/Questionable/Validation/Validators/SinglePlayerInstanceValidator.cs b/Questionable/Validation/Validators/SinglePlayerInstanceValidator.cs
new file mode 100644 (file)
index 0000000..60a838f
--- /dev/null
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Questionable.Data;
+using Questionable.Model;
+using Questionable.Model.Questing;
+
+namespace Questionable.Validation.Validators;
+
+internal sealed class SinglePlayerInstanceValidator : IQuestValidator
+{
+    private readonly Dictionary<ElementId, List<byte>> _questIdToDutyIndexes;
+
+    public SinglePlayerInstanceValidator(TerritoryData territoryData)
+    {
+        _questIdToDutyIndexes = territoryData.GetAllQuestsWithQuestBattles()
+            .GroupBy(x => x.QuestId)
+            .ToDictionary(x => x.Key, x => x.Select(y => y.Index).ToList());
+    }
+
+    public IEnumerable<ValidationIssue> Validate(Quest quest)
+    {
+        if (_questIdToDutyIndexes.TryGetValue(quest.Id, out var indexes))
+        {
+            foreach (var index in indexes)
+            {
+                if (quest.AllSteps().Any(x =>
+                        x.Step.InteractionType == EInteractionType.SinglePlayerDuty &&
+                        x.Step.SinglePlayerDutyIndex == index))
+                    continue;
+
+                yield return new ValidationIssue
+                {
+                    ElementId = quest.Id,
+                    Sequence = null,
+                    Step = null,
+                    Type = EIssueType.UnusedSinglePlayerInstance,
+                    Severity = EIssueSeverity.Error,
+                    Description = $"Single player instance {index} not used",
+                };
+            }
+        }
+    }
+}
index 0a5be627d27dacae241529fdf8e90233bec01104..e82cf07b6729a7bdc86247f9b81c05d9b4f3bc0c 100644 (file)
@@ -39,12 +39,12 @@ internal abstract class ConfigComponent
 
     protected void Save() => _pluginInterface.SavePluginConfig(Configuration);
 
-    protected static string FormatLevel(int level)
+    protected static string FormatLevel(int level, bool includePrefix = true)
     {
         if (level == 0)
             return string.Empty;
 
-        return $"{FormatLevel(level / 10)}{(SeIconChar.Number0 + level % 10).ToIconChar()}";
+        return $"{(includePrefix ? SeIconChar.LevelEn.ToIconString() : string.Empty)}{FormatLevel(level / 10, false)}{(SeIconChar.Number0 + level % 10).ToIconChar()}";
     }
 
     /// <summary>
index 7d89efd071f82200ecb3ac79254eb58409cb1a2a..c410f3fbc7b54b23279965fefb43280e8af02f11 100644 (file)
@@ -14,7 +14,7 @@ internal sealed class DebugConfigComponent : ConfigComponent
 
     public override void DrawTab()
     {
-        using var tab = ImRaii.TabItem("Advanced");
+        using var tab = ImRaii.TabItem("Advanced###Debug");
         if (!tab)
             return;
 
index ffb6538a7df60edabfdf688e60fbffa998bc5f5e..a04319e1f0ec90f31a4a787b5a85742069c69f81 100644 (file)
@@ -60,14 +60,13 @@ internal sealed class DutyConfigComponent : ConfigComponent
             .GroupBy(x => x.Expansion)
             .ToDictionary(x => x.Key,
                 x => x
-                    .Select(y => new DutyInfo(y.CfcId, y.TerritoryId,
-                        $"{SeIconChar.LevelEn.ToIconChar()}{FormatLevel(y.Level)} {y.Name}"))
+                    .Select(y => new DutyInfo(y.CfcId, y.TerritoryId, $"{FormatLevel(y.Level)} {y.Name}"))
                     .ToList());
     }
 
     public override void DrawTab()
     {
-        using var tab = ImRaii.TabItem("Duties");
+        using var tab = ImRaii.TabItem("Duties###Duties");
         if (!tab)
             return;
 
@@ -96,37 +95,25 @@ internal sealed class DutyConfigComponent : ConfigComponent
                     "https://docs.google.com/spreadsheets/d/151RlpqRcCpiD_VbQn6Duf-u-S71EP7d0mx3j1PDNoNA/edit?pli=1#gid=0");
 
             ImGui.Separator();
-            ImGui.Text("You can override the dungeon settings for each individual dungeon/trial:");
+            ImGui.Text("You can override the settings for each individual dungeon/trial:");
 
             DrawConfigTable(runInstancedContentWithAutoDuty);
-            DrawClipboardButtons();
 
+            DrawClipboardButtons();
             ImGui.SameLine();
-
-            using (var unused = ImRaii.Disabled(!ImGui.IsKeyDown(ImGuiKey.ModCtrl)))
-            {
-                if (ImGui.Button("Reset to default"))
-                {
-                    Configuration.Duties.WhitelistedDutyCfcIds.Clear();
-                    Configuration.Duties.BlacklistedDutyCfcIds.Clear();
-                    Save();
-                }
-            }
-
-            if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
-                ImGui.SetTooltip("Hold CTRL to enable this button.");
+            DrawResetButton();
         }
     }
 
     private void DrawConfigTable(bool runInstancedContentWithAutoDuty)
     {
-        using var child = ImRaii.Child("DutyConfiguration", new Vector2(-1, 400), true);
+        using var child = ImRaii.Child("DutyConfiguration", new Vector2(650, 400), true);
         if (!child)
             return;
 
         foreach (EExpansionVersion expansion in Enum.GetValues<EExpansionVersion>())
         {
-            if (ImGui.CollapsingHeader(expansion.ToString()))
+            if (ImGui.CollapsingHeader(expansion.ToFriendlyString()))
             {
                 using var table = ImRaii.Table($"Duties{expansion}", 2, ImGuiTableFlags.SizingFixedFit);
                 if (table)
@@ -245,5 +232,21 @@ internal sealed class DutyConfigComponent : ConfigComponent
         }
     }
 
+    private void DrawResetButton()
+    {
+        using (ImRaii.Disabled(!ImGui.IsKeyDown(ImGuiKey.ModCtrl)))
+        {
+            if (ImGui.Button("Reset to default"))
+            {
+                Configuration.Duties.WhitelistedDutyCfcIds.Clear();
+                Configuration.Duties.BlacklistedDutyCfcIds.Clear();
+                Save();
+            }
+        }
+
+        if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
+            ImGui.SetTooltip("Hold CTRL to enable this button.");
+    }
+
     private sealed record DutyInfo(uint CfcId, uint TerritoryId, string Name);
 }
index e58ce989ec95ef48ee6e7dcd2ecd22a5ec838b94..9f71d7a60a4f02955844c9206e7142d961056e03 100644 (file)
@@ -45,7 +45,7 @@ internal sealed class GeneralConfigComponent : ConfigComponent
 
     public override void DrawTab()
     {
-        using var tab = ImRaii.TabItem("General");
+        using var tab = ImRaii.TabItem("General###General");
         if (!tab)
             return;
 
index d0a4ba0dd79e37731ea9a9d2067e75b59b3c265a..5df122a562cfe2e31314d26a95b0bf1697f5b7e3 100644 (file)
@@ -25,7 +25,7 @@ internal sealed class NotificationConfigComponent : ConfigComponent
 
     public override void DrawTab()
     {
-        using var tab = ImRaii.TabItem("Notifications");
+        using var tab = ImRaii.TabItem("Notifications###Notifications");
         if (!tab)
             return;
 
diff --git a/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs
new file mode 100644 (file)
index 0000000..443ccd8
--- /dev/null
@@ -0,0 +1,464 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Numerics;
+using Dalamud.Game.Text;
+using Dalamud.Interface;
+using Dalamud.Interface.Colors;
+using Dalamud.Interface.Components;
+using Dalamud.Interface.Utility.Raii;
+using Dalamud.Plugin;
+using Dalamud.Plugin.Services;
+using ImGuiNET;
+using LLib.GameData;
+using Lumina.Excel.Sheets;
+using Microsoft.Extensions.Logging;
+using Questionable.Controller;
+using Questionable.Controller.Steps.Interactions;
+using Questionable.Data;
+using Questionable.Model;
+using Questionable.Model.Common;
+using Questionable.Model.Questing;
+
+namespace Questionable.Windows.ConfigComponents;
+
+internal sealed class SinglePlayerDutyConfigComponent : ConfigComponent
+{
+    private readonly TerritoryData _territoryData;
+    private readonly QuestRegistry _questRegistry;
+    private readonly QuestData _questData;
+    private readonly IDataManager _dataManager;
+    private readonly ILogger<SinglePlayerDutyConfigComponent> _logger;
+
+    private static readonly List<(EClassJob ClassJob, string Name)> RoleQuestCategories =
+    [
+        (EClassJob.Paladin, "Tank Role Quests"),
+        (EClassJob.WhiteMage, "Healer Role Quests"),
+        (EClassJob.Lancer, "Melee Role Quests"),
+        (EClassJob.Bard, "Physical Ranged Role Quests"),
+        (EClassJob.BlackMage, "Magical Ranged Role Quests"),
+    ];
+
+    private ImmutableDictionary<EAetheryteLocation, List<SinglePlayerDutyInfo>> _startingCityBattles = ImmutableDictionary<EAetheryteLocation, List<SinglePlayerDutyInfo>>.Empty;
+    private ImmutableDictionary<EExpansionVersion, List<SinglePlayerDutyInfo>> _mainScenarioBattles = ImmutableDictionary<EExpansionVersion, List<SinglePlayerDutyInfo>>.Empty;
+    private ImmutableDictionary<EClassJob, List<SinglePlayerDutyInfo>> _jobQuestBattles = ImmutableDictionary<EClassJob, List<SinglePlayerDutyInfo>>.Empty;
+    private ImmutableDictionary<EClassJob, List<SinglePlayerDutyInfo>> _roleQuestBattles = ImmutableDictionary<EClassJob, List<SinglePlayerDutyInfo>>.Empty;
+    private ImmutableList<SinglePlayerDutyInfo> _otherRoleQuestBattles = ImmutableList<SinglePlayerDutyInfo>.Empty;
+    private ImmutableList<(string Label, List<SinglePlayerDutyInfo>)> _otherQuestBattles = ImmutableList<(string Label, List<SinglePlayerDutyInfo>)>.Empty;
+
+    public SinglePlayerDutyConfigComponent(
+        IDalamudPluginInterface pluginInterface,
+        Configuration configuration,
+        TerritoryData territoryData,
+        QuestRegistry questRegistry,
+        QuestData questData,
+        IDataManager dataManager,
+        ILogger<SinglePlayerDutyConfigComponent> logger)
+        : base(pluginInterface, configuration)
+    {
+        _territoryData = territoryData;
+        _questRegistry = questRegistry;
+        _questData = questData;
+        _dataManager = dataManager;
+        _logger = logger;
+    }
+
+    public void Reload()
+    {
+        List<ElementId> questsWithMultipleBattles = _territoryData.GetAllQuestsWithQuestBattles()
+            .GroupBy(x => x.QuestId)
+            .Where(x => x.Count() > 1)
+            .Select(x => x.Key)
+            .ToList();
+
+        List<SinglePlayerDutyInfo> mainScenarioBattles = [];
+        Dictionary<EAetheryteLocation, List<SinglePlayerDutyInfo>> startingCityBattles =
+            new()
+            {
+                { EAetheryteLocation.Limsa, [] },
+                { EAetheryteLocation.Gridania, [] },
+                { EAetheryteLocation.Uldah, [] },
+            };
+
+        List<SinglePlayerDutyInfo> otherBattles = [];
+
+        Dictionary<ElementId, EClassJob> questIdsToJob = Enum.GetValues<EClassJob>()
+            .Where(x => x != EClassJob.Adventurer && !x.IsCrafter() && !x.IsGatherer())
+            .Where(x => x.IsClass() || !x.HasBaseClass())
+            .SelectMany(x => _questRegistry.GetKnownClassJobQuests(x, false).Select(y => (y.QuestId, ClassJob: x)))
+            .ToDictionary(x => x.QuestId, x => x.ClassJob);
+        Dictionary<EClassJob, List<SinglePlayerDutyInfo>> jobQuestBattles = questIdsToJob.Values.Distinct()
+            .ToDictionary(x => x, _ => new List<SinglePlayerDutyInfo>());
+
+        Dictionary<ElementId, List<EClassJob>> questIdToRole = RoleQuestCategories
+            .SelectMany(x => _questData.GetRoleQuests(x.ClassJob).Select(y => (y.QuestId, x.ClassJob)))
+            .GroupBy(x => x.QuestId)
+            .ToDictionary(x => x.Key, x => x.Select(y => y.ClassJob).ToList());
+        Dictionary<EClassJob, List<SinglePlayerDutyInfo>> roleQuestBattles = RoleQuestCategories
+            .ToDictionary(x => x.ClassJob, _ => new List<SinglePlayerDutyInfo>());
+        List<SinglePlayerDutyInfo> otherRoleQuestBattles = [];
+
+        foreach (var (questId, index, cfcData) in _territoryData.GetAllQuestsWithQuestBattles())
+        {
+            IQuestInfo questInfo = _questData.GetQuestInfo(questId);
+            QuestStep questStep = new QuestStep
+                {
+                    SinglePlayerDutyIndex = 0,
+                    BossModEnabled = false,
+                };
+            bool enabled;
+            if (_questRegistry.TryGetQuest(questId, out var quest))
+            {
+                if (quest.Root.Disabled)
+                {
+                    _logger.LogDebug("Disabling quest battle for quest {QuestId}, quest is disabled", questId);
+                    enabled = false;
+                }
+                else
+                {
+                    var foundStep = quest.AllSteps().FirstOrDefault(x =>
+                        x.Step.InteractionType == EInteractionType.SinglePlayerDuty &&
+                        x.Step.SinglePlayerDutyIndex == index);
+                    if (foundStep == default)
+                    {
+                        _logger.LogWarning("Disabling quest battle for quest {QuestId}, no battle with index {Index} found", questId, index);
+                        enabled = false;
+                    }
+                    else
+                    {
+                        questStep = foundStep.Step;
+                        enabled = true;
+                    }
+                }
+            }
+            else
+            {
+                _logger.LogDebug("Disabling quest battle for quest {QuestId}, unknown quest", questId);
+                enabled = false;
+            }
+
+            string name = $"{FormatLevel(questInfo.Level)} {questInfo.Name}";
+            if (!string.IsNullOrEmpty(cfcData.Name) && !questInfo.Name.EndsWith(cfcData.Name, StringComparison.Ordinal))
+                name += $" ({cfcData.Name})";
+
+            if (questsWithMultipleBattles.Contains(questId))
+                name += $" (Part {questStep.SinglePlayerDutyIndex + 1})";
+            else if (cfcData.ContentFinderConditionId is 674 or 691)
+                name += " (Melee/Phys. Ranged)";
+
+            var dutyInfo = new SinglePlayerDutyInfo(
+                cfcData.ContentFinderConditionId,
+                cfcData.TerritoryId,
+                name,
+                questInfo.Expansion,
+                questInfo.JournalGenre ?? uint.MaxValue,
+                questInfo.SortKey,
+                questStep.SinglePlayerDutyIndex,
+                enabled,
+                questStep.BossModEnabled);
+
+            if (cfcData.ContentFinderConditionId is 332 or 333 or 313 or 334)
+                startingCityBattles[EAetheryteLocation.Limsa].Add(dutyInfo);
+            else if (cfcData.ContentFinderConditionId is 296 or 297 or 299 or 298)
+                startingCityBattles[EAetheryteLocation.Gridania].Add(dutyInfo);
+            else if (cfcData.ContentFinderConditionId is 335 or 312 or 337 or 336)
+                startingCityBattles[EAetheryteLocation.Uldah].Add(dutyInfo);
+            else if (questInfo.IsMainScenarioQuest)
+                mainScenarioBattles.Add(dutyInfo);
+            else if (questIdsToJob.TryGetValue(questId, out EClassJob classJob))
+                jobQuestBattles[classJob].Add(dutyInfo);
+            else if (questIdToRole.TryGetValue(questId, out var classJobs))
+            {
+                foreach (var roleClassJob in classJobs)
+                    roleQuestBattles[roleClassJob].Add(dutyInfo);
+            }
+            else if (dutyInfo.CfcId is 845 or 1016)
+                otherRoleQuestBattles.Add(dutyInfo);
+            else
+                otherBattles.Add(dutyInfo);
+        }
+
+        _startingCityBattles = startingCityBattles
+            .ToImmutableDictionary(x => x.Key,
+                x => x.Value.OrderBy(y => y.SortKey)
+                    .ToList());
+        _mainScenarioBattles = mainScenarioBattles
+            .GroupBy(x => x.Expansion)
+            .ToImmutableDictionary(x => x.Key,
+                x =>
+                    x.OrderBy(y => y.JournalGenreId)
+                        .ThenBy(y => y.SortKey)
+                        .ThenBy(y => y.Index)
+                        .ToList());
+        _jobQuestBattles = jobQuestBattles
+            .Where(x => x.Value.Count > 0)
+            .ToImmutableDictionary(x => x.Key,
+                x =>
+                    x.Value
+                        // level 10 quests use the same quest battle for [you started as this class] and [you picked this class up later]
+                        .DistinctBy(y => y.CfcId)
+                        .OrderBy(y => y.JournalGenreId)
+                        .ThenBy(y => y.SortKey)
+                        .ThenBy(y => y.Index)
+                        .ToList());
+        _roleQuestBattles = roleQuestBattles
+            .ToImmutableDictionary(x => x.Key,
+                x =>
+                    x.Value.OrderBy(y => y.JournalGenreId)
+                        .ThenBy(y => y.SortKey)
+                        .ThenBy(y => y.Index)
+                        .ToList());
+        _otherRoleQuestBattles = otherRoleQuestBattles.ToImmutableList();
+        _otherQuestBattles = otherBattles
+            .OrderBy(x => x.JournalGenreId)
+            .ThenBy(x => x.SortKey)
+            .ThenBy(x => x.Index)
+            .GroupBy(x => x.JournalGenreId)
+            .Select(x => (BuildJournalGenreLabel(x.Key), x.ToList()))
+            .ToImmutableList();
+    }
+
+    private string BuildJournalGenreLabel(uint journalGenreId)
+    {
+        var journalGenre = _dataManager.GetExcelSheet<JournalGenre>().GetRow(journalGenreId);
+        var journalCategory = journalGenre.JournalCategory.Value;
+
+        string genreName = journalGenre.Name.ExtractText();
+        string categoryName = journalCategory.Name.ExtractText();
+
+        return $"{categoryName} {SeIconChar.ArrowRight.ToIconString()} {genreName}";
+    }
+
+    public override void DrawTab()
+    {
+        using var tab = ImRaii.TabItem("Quest Battles###QuestBattles");
+        if (!tab)
+            return;
+
+        bool runSoloInstancesWithBossMod = Configuration.SinglePlayerDuties.RunSoloInstancesWithBossMod;
+        if (ImGui.Checkbox("Run quest battles with BossMod", ref runSoloInstancesWithBossMod))
+        {
+            Configuration.SinglePlayerDuties.RunSoloInstancesWithBossMod = runSoloInstancesWithBossMod;
+            Save();
+        }
+
+        ImGui.TextColored(ImGuiColors.DalamudRed,
+            "Work in Progress: For now, this will always use BossMod for combat.");
+
+        ImGui.Separator();
+
+        using (ImRaii.Disabled(!runSoloInstancesWithBossMod))
+        {
+            ImGui.Text(
+                "Questionable includes a default list of quest battles that work if BossMod is installed.");
+            ImGui.Text("The included list of quest battles can change with each update.");
+
+            ImGui.Separator();
+            ImGui.Text("You can override the settings for each individual quest battle:");
+
+
+            using var tabBar = ImRaii.TabBar("QuestionableConfigTabs");
+            if (tabBar)
+            {
+                DrawMainScenarioConfigTable();
+                DrawJobQuestConfigTable();
+                DrawRoleQuestConfigTable();
+                DrawOtherQuestConfigTable();
+            }
+
+            DrawResetButton();
+        }
+    }
+
+    private void DrawMainScenarioConfigTable()
+    {
+        using var tab = ImRaii.TabItem("MSQ###MSQ");
+        if (!tab)
+            return;
+
+        using var child = BeginChildArea();
+        if (!child)
+            return;
+
+        if (ImGui.CollapsingHeader($"Limsa Lominsa ({FormatLevel(5)} - {FormatLevel(14)})"))
+            DrawQuestTable("LimsaLominsa", _startingCityBattles[EAetheryteLocation.Limsa]);
+
+        if (ImGui.CollapsingHeader($"Gridania ({FormatLevel(5)} - {FormatLevel(14)})"))
+            DrawQuestTable("Gridania", _startingCityBattles[EAetheryteLocation.Gridania]);
+
+        if (ImGui.CollapsingHeader($"Ul'dah ({FormatLevel(4)} - {FormatLevel(14)})"))
+            DrawQuestTable("Uldah", _startingCityBattles[EAetheryteLocation.Uldah]);
+
+        foreach (EExpansionVersion expansion in Enum.GetValues<EExpansionVersion>())
+        {
+            if (_mainScenarioBattles.TryGetValue(expansion, out var dutyInfos))
+            {
+                if (ImGui.CollapsingHeader(expansion.ToFriendlyString()))
+                    DrawQuestTable($"Duties{expansion}", dutyInfos);
+            }
+        }
+    }
+
+    private void DrawJobQuestConfigTable()
+    {
+        using var tab = ImRaii.TabItem("Class/Job Quests###JobQuests");
+        if (!tab)
+            return;
+
+        using var child = BeginChildArea();
+        if (!child)
+            return;
+
+        foreach (EClassJob classJob in Enum.GetValues<EClassJob>())
+        {
+            if (_jobQuestBattles.TryGetValue(classJob, out var dutyInfos))
+            {
+                string jobName = classJob.ToFriendlyString();
+                if (classJob.IsClass())
+                    jobName += $" / {classJob.AsJob().ToFriendlyString()}";
+
+                if (ImGui.CollapsingHeader(jobName))
+                    DrawQuestTable($"JobQuests{classJob}", dutyInfos);
+            }
+        }
+    }
+
+    private void DrawRoleQuestConfigTable()
+    {
+        using var tab = ImRaii.TabItem("Role Quests###RoleQuests");
+        if (!tab)
+            return;
+
+        using var child = BeginChildArea();
+        if (!child)
+            return;
+
+        foreach (var (classJob, label) in RoleQuestCategories)
+        {
+            if (_roleQuestBattles.TryGetValue(classJob, out var dutyInfos))
+            {
+                if (ImGui.CollapsingHeader(label))
+                    DrawQuestTable($"RoleQuests{classJob}", dutyInfos);
+            }
+        }
+
+        if(ImGui.CollapsingHeader("General Role Quests"))
+            DrawQuestTable("RoleQuestsGeneral", _otherRoleQuestBattles);
+    }
+
+    private void DrawOtherQuestConfigTable()
+    {
+        using var tab = ImRaii.TabItem("Other Quests###MiscQuests");
+        if (!tab)
+            return;
+
+        using var child = BeginChildArea();
+        if (!child)
+            return;
+
+        foreach (var (label, dutyInfos) in _otherQuestBattles)
+        {
+            if (ImGui.CollapsingHeader(label))
+                DrawQuestTable($"Other{label}", dutyInfos);
+        }
+    }
+
+    private void DrawQuestTable(string label, IReadOnlyList<SinglePlayerDutyInfo> dutyInfos)
+    {
+        using var table = ImRaii.Table(label, 2, ImGuiTableFlags.SizingFixedFit);
+        if (table)
+        {
+            ImGui.TableSetupColumn("Quest", ImGuiTableColumnFlags.WidthStretch);
+            ImGui.TableSetupColumn("Options", ImGuiTableColumnFlags.WidthFixed, 200f);
+
+            foreach (var dutyInfo in dutyInfos)
+            {
+                ImGui.TableNextRow();
+
+                string[] labels = dutyInfo.BossModEnabledByDefault
+                    ? SupportedCfcOptions
+                    : UnsupportedCfcOptions;
+                int value = 0;
+                if (Configuration.Duties.WhitelistedDutyCfcIds.Contains(dutyInfo.CfcId))
+                    value = 1;
+                if (Configuration.Duties.BlacklistedDutyCfcIds.Contains(dutyInfo.CfcId))
+                    value = 2;
+
+                if (ImGui.TableNextColumn())
+                {
+                    ImGui.AlignTextToFramePadding();
+                    ImGui.TextUnformatted(dutyInfo.Name);
+
+                    if (ImGui.IsItemHovered() && Configuration.Advanced.AdditionalStatusInformation)
+                    {
+                        using var tooltip = ImRaii.Tooltip();
+                        if (tooltip)
+                        {
+                            ImGui.TextUnformatted(dutyInfo.Name);
+                            ImGui.Separator();
+                            ImGui.BulletText($"TerritoryId: {dutyInfo.TerritoryId}");
+                            ImGui.BulletText($"ContentFinderConditionId: {dutyInfo.CfcId}");
+                        }
+                    }
+
+                    if (!dutyInfo.Enabled)
+                    {
+                        ImGuiComponents.HelpMarker("Questionable doesn't include support for this quest.",
+                            FontAwesomeIcon.Times, ImGuiColors.DalamudRed);
+                    }
+                }
+
+                if (ImGui.TableNextColumn())
+                {
+                    using var _ = ImRaii.PushId($"##Duty{dutyInfo.CfcId}");
+                    using (ImRaii.Disabled(!dutyInfo.Enabled))
+                    {
+                        ImGui.SetNextItemWidth(200);
+                        if (ImGui.Combo(string.Empty, ref value, labels, labels.Length))
+                        {
+                            Configuration.Duties.WhitelistedDutyCfcIds.Remove(dutyInfo.CfcId);
+                            Configuration.Duties.BlacklistedDutyCfcIds.Remove(dutyInfo.CfcId);
+
+                            if (value == 1)
+                                Configuration.Duties.WhitelistedDutyCfcIds.Add(dutyInfo.CfcId);
+                            else if (value == 2)
+                                Configuration.Duties.BlacklistedDutyCfcIds.Add(dutyInfo.CfcId);
+
+                            Save();
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private static ImRaii.IEndObject BeginChildArea() => ImRaii.Child("DutyConfiguration", new Vector2(650, 400), true);
+
+    private void DrawResetButton()
+    {
+        using (ImRaii.Disabled(!ImGui.IsKeyDown(ImGuiKey.ModCtrl)))
+        {
+            if (ImGui.Button("Reset to default"))
+            {
+                Configuration.SinglePlayerDuties.WhitelistedSinglePlayerDutyCfcIds.Clear();
+                Configuration.SinglePlayerDuties.BlacklistedSinglePlayerDutyCfcIds.Clear();
+                Save();
+            }
+        }
+
+        if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
+            ImGui.SetTooltip("Hold CTRL to enable this button.");
+    }
+
+    private sealed record SinglePlayerDutyInfo(
+        uint CfcId,
+        uint TerritoryId,
+        string Name,
+        EExpansionVersion Expansion,
+        uint JournalGenreId,
+        ushort SortKey,
+        byte Index,
+        bool Enabled,
+        bool BossModEnabledByDefault);
+}
index e2ac6c314997ec6f26484f8e681289d0ab02804c..f3611a5bb0f0c85a6ab798b54bbd853fab92056b 100644 (file)
@@ -2,6 +2,7 @@
 using Dalamud.Plugin;
 using ImGuiNET;
 using LLib.ImGui;
+using Questionable.Controller.Steps.Interactions;
 using Questionable.Windows.ConfigComponents;
 
 namespace Questionable.Windows;
@@ -11,6 +12,7 @@ internal sealed class ConfigWindow : LWindow, IPersistableWindowConfig
     private readonly IDalamudPluginInterface _pluginInterface;
     private readonly GeneralConfigComponent _generalConfigComponent;
     private readonly DutyConfigComponent _dutyConfigComponent;
+    private readonly SinglePlayerDutyConfigComponent _singlePlayerDutyConfigComponent;
     private readonly NotificationConfigComponent _notificationConfigComponent;
     private readonly DebugConfigComponent _debugConfigComponent;
     private readonly Configuration _configuration;
@@ -19,6 +21,7 @@ internal sealed class ConfigWindow : LWindow, IPersistableWindowConfig
         IDalamudPluginInterface pluginInterface,
         GeneralConfigComponent generalConfigComponent,
         DutyConfigComponent dutyConfigComponent,
+        SinglePlayerDutyConfigComponent singlePlayerDutyConfigComponent,
         NotificationConfigComponent notificationConfigComponent,
         DebugConfigComponent debugConfigComponent,
         Configuration configuration)
@@ -27,6 +30,7 @@ internal sealed class ConfigWindow : LWindow, IPersistableWindowConfig
         _pluginInterface = pluginInterface;
         _generalConfigComponent = generalConfigComponent;
         _dutyConfigComponent = dutyConfigComponent;
+        _singlePlayerDutyConfigComponent = singlePlayerDutyConfigComponent;
         _notificationConfigComponent = notificationConfigComponent;
         _debugConfigComponent = debugConfigComponent;
         _configuration = configuration;
@@ -42,6 +46,7 @@ internal sealed class ConfigWindow : LWindow, IPersistableWindowConfig
 
         _generalConfigComponent.DrawTab();
         _dutyConfigComponent.DrawTab();
+        _singlePlayerDutyConfigComponent.DrawTab();
         _notificationConfigComponent.DrawTab();
         _debugConfigComponent.DrawTab();
     }
index d147acc0ea5eed00e25b12508bf5d3fb8eefed53..70c0e31aabfbc7067c5b57fd02ee0c72ebc7a22e 160000 (submodule)
@@ -1 +1 @@
-Subproject commit d147acc0ea5eed00e25b12508bf5d3fb8eefed53
+Subproject commit 70c0e31aabfbc7067c5b57fd02ee0c72ebc7a22e