From a75286e92745c03bb1a212f171ebec8f4acc99c7 Mon Sep 17 00:00:00 2001 From: Liza Carvelli Date: Fri, 21 Feb 2025 02:17:47 +0100 Subject: [PATCH] Add UI to enable/disable quest battles --- LLib | 2 +- .../Class Quests/DRG/2914_Dragon Sound.json | 2 +- .../2900_Curious Gorge Meets His Match.json | 2 +- Questionable/Configuration.cs | 8 +- Questionable/Controller/QuestController.cs | 7 +- Questionable/Controller/QuestRegistry.cs | 6 +- Questionable/Data/QuestData.cs | 25 +- Questionable/Data/TerritoryData.cs | 5 + Questionable/External/BossModIpc.cs | 6 +- Questionable/QuestionablePlugin.cs | 3 + Questionable/Validation/EIssueType.cs | 1 + .../SinglePlayerInstanceValidator.cs | 44 ++ .../ConfigComponents/ConfigComponent.cs | 4 +- .../ConfigComponents/DebugConfigComponent.cs | 2 +- .../ConfigComponents/DutyConfigComponent.cs | 43 +- .../GeneralConfigComponent.cs | 2 +- .../NotificationConfigComponent.cs | 2 +- .../SinglePlayerDutyConfigComponent.cs | 464 ++++++++++++++++++ Questionable/Windows/ConfigWindow.cs | 5 + vendor/pictomancy | 2 +- 20 files changed, 588 insertions(+), 47 deletions(-) create mode 100644 Questionable/Validation/Validators/SinglePlayerInstanceValidator.cs create mode 100644 Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs diff --git a/LLib b/LLib index 746d14681..edab3c7ec 160000 --- a/LLib +++ b/LLib @@ -1 +1 @@ -Subproject commit 746d14681baa91132784ab17f8f49671e86ea211 +Subproject commit edab3c7ecc6bd66ac07e3c3938eb9c8a835a1c42 diff --git a/QuestPaths/4.x - Stormblood/Class Quests/DRG/2914_Dragon Sound.json b/QuestPaths/4.x - Stormblood/Class Quests/DRG/2914_Dragon Sound.json index 3561d3c05..fe27abf7a 100644 --- a/QuestPaths/4.x - Stormblood/Class Quests/DRG/2914_Dragon Sound.json +++ b/QuestPaths/4.x - Stormblood/Class Quests/DRG/2914_Dragon Sound.json @@ -34,7 +34,7 @@ "Z": -509.51404 }, "TerritoryId": 622, - "InteractionType": "Interact", + "InteractionType": "SinglePlayerDuty", "Fly": true } ] diff --git a/QuestPaths/4.x - Stormblood/Class Quests/WAR/2900_Curious Gorge Meets His Match.json b/QuestPaths/4.x - Stormblood/Class Quests/WAR/2900_Curious Gorge Meets His Match.json index d17c860b1..12c31df17 100644 --- a/QuestPaths/4.x - Stormblood/Class Quests/WAR/2900_Curious Gorge Meets His Match.json +++ b/QuestPaths/4.x - Stormblood/Class Quests/WAR/2900_Curious Gorge Meets His Match.json @@ -35,7 +35,7 @@ "Z": 686.427 }, "TerritoryId": 135, - "InteractionType": "Interact", + "InteractionType": "SinglePlayerDuty", "AetheryteShortcut": "Lower La Noscea - Moraby Drydocks" } ] diff --git a/Questionable/Configuration.cs b/Questionable/Configuration.cs index 74bb05e6e..a4126ed6f 100644 --- a/Questionable/Configuration.cs +++ b/Questionable/Configuration.cs @@ -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 BlacklistedDutyCfcIds { get; set; } = []; } - internal sealed class SoloDutyConfiguration + internal sealed class SinglePlayerDutyConfiguration { public bool RunSoloInstancesWithBossMod { get; set; } - public HashSet WhitelistedSoloDutyCfcIds { get; set; } = []; - public HashSet BlacklistedSoloDutyCfcIds { get; set; } = []; + public HashSet WhitelistedSinglePlayerDutyCfcIds { get; set; } = []; + public HashSet BlacklistedSinglePlayerDutyCfcIds { get; set; } = []; } internal sealed class NotificationConfiguration diff --git a/Questionable/Controller/QuestController.cs b/Questionable/Controller/QuestController.cs index ce92099eb..9fbaadfdd 100644 --- a/Questionable/Controller/QuestController.cs +++ b/Questionable/Controller/QuestController.cs @@ -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 private readonly Configuration _configuration; private readonly YesAlreadyIpc _yesAlreadyIpc; private readonly TaskCreator _taskCreator; + private readonly SinglePlayerDutyConfigComponent _singlePlayerDutyConfigComponent; private readonly ILogger _logger; private readonly object _progressLock = new(); @@ -76,7 +78,8 @@ internal sealed class QuestController : MiniTaskController 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 _configuration = configuration; _yesAlreadyIpc = yesAlreadyIpc; _taskCreator = taskCreator; + _singlePlayerDutyConfigComponent = singlePlayerDutyConfigComponent; _logger = logger; _condition.ConditionChange += OnConditionChange; @@ -169,6 +173,7 @@ internal sealed class QuestController : MiniTaskController DebugState = null; _questRegistry.Reload(); + _singlePlayerDutyConfigComponent.Reload(); } } diff --git a/Questionable/Controller/QuestRegistry.cs b/Questionable/Controller/QuestRegistry.cs index 5e948761d..0e066c96b 100644 --- a/Questionable/Controller/QuestRegistry.cs +++ b/Questionable/Controller/QuestRegistry.cs @@ -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 GetKnownClassJobQuests(EClassJob classJob) + public List GetKnownClassJobQuests(EClassJob classJob, bool includeRoleQuests = true) { - List allQuests = [.._questData.GetClassJobQuests(classJob)]; + List 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)) diff --git a/Questionable/Data/QuestData.cs b/Questionable/Data/QuestData.cs index f84edb959..e3cc60f19 100644 --- a/Questionable/Data/QuestData.cs +++ b/Questionable/Data/QuestData.cs @@ -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 GetClassJobQuests(EClassJob classJob) + public List GetClassJobQuests(EClassJob classJob, bool includeRoleQuests = false) { List 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 GetRoleQuests(EClassJob referenceClassJob) => + GetQuestsInNewGamePlusChapters(GetRoleQuestIds(referenceClassJob).ToList()); + + private static IEnumerable 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 GetQuestsInNewGamePlusChapters(List chapterIds) diff --git a/Questionable/Data/TerritoryData.cs b/Questionable/Data/TerritoryData.cs index c57ada43a..f35f9cee7 100644 --- a/Questionable/Data/TerritoryData.cs +++ b/Questionable/Data/TerritoryData.cs @@ -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) diff --git a/Questionable/External/BossModIpc.cs b/Questionable/External/BossModIpc.cs index 82a3de68d..939a35d7a 100644 --- a/Questionable/External/BossModIpc.cs +++ b/Questionable/External/BossModIpc.cs @@ -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; diff --git a/Questionable/QuestionablePlugin.cs b/Questionable/QuestionablePlugin.cs index 0f776eb62..e6a82af2d 100644 --- a/Questionable/QuestionablePlugin.cs +++ b/Questionable/QuestionablePlugin.cs @@ -302,6 +302,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); } @@ -317,6 +318,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(sp => sp.GetRequiredService()); @@ -326,6 +328,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin { serviceProvider.GetRequiredService().Reload(); serviceProvider.GetRequiredService().Reload(); + serviceProvider.GetRequiredService().Reload(); serviceProvider.GetRequiredService(); serviceProvider.GetRequiredService(); serviceProvider.GetRequiredService(); diff --git a/Questionable/Validation/EIssueType.cs b/Questionable/Validation/EIssueType.cs index 4d41f9970..3f725738a 100644 --- a/Questionable/Validation/EIssueType.cs +++ b/Questionable/Validation/EIssueType.cs @@ -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 index 000000000..60a838f52 --- /dev/null +++ b/Questionable/Validation/Validators/SinglePlayerInstanceValidator.cs @@ -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> _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 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", + }; + } + } + } +} diff --git a/Questionable/Windows/ConfigComponents/ConfigComponent.cs b/Questionable/Windows/ConfigComponents/ConfigComponent.cs index 0a5be627d..e82cf07b6 100644 --- a/Questionable/Windows/ConfigComponents/ConfigComponent.cs +++ b/Questionable/Windows/ConfigComponents/ConfigComponent.cs @@ -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()}"; } /// diff --git a/Questionable/Windows/ConfigComponents/DebugConfigComponent.cs b/Questionable/Windows/ConfigComponents/DebugConfigComponent.cs index 7d89efd07..c410f3fbc 100644 --- a/Questionable/Windows/ConfigComponents/DebugConfigComponent.cs +++ b/Questionable/Windows/ConfigComponents/DebugConfigComponent.cs @@ -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; diff --git a/Questionable/Windows/ConfigComponents/DutyConfigComponent.cs b/Questionable/Windows/ConfigComponents/DutyConfigComponent.cs index ffb6538a7..a04319e1f 100644 --- a/Questionable/Windows/ConfigComponents/DutyConfigComponent.cs +++ b/Questionable/Windows/ConfigComponents/DutyConfigComponent.cs @@ -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(); - 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()) { - 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); } diff --git a/Questionable/Windows/ConfigComponents/GeneralConfigComponent.cs b/Questionable/Windows/ConfigComponents/GeneralConfigComponent.cs index e58ce989e..9f71d7a60 100644 --- a/Questionable/Windows/ConfigComponents/GeneralConfigComponent.cs +++ b/Questionable/Windows/ConfigComponents/GeneralConfigComponent.cs @@ -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; diff --git a/Questionable/Windows/ConfigComponents/NotificationConfigComponent.cs b/Questionable/Windows/ConfigComponents/NotificationConfigComponent.cs index d0a4ba0dd..5df122a56 100644 --- a/Questionable/Windows/ConfigComponents/NotificationConfigComponent.cs +++ b/Questionable/Windows/ConfigComponents/NotificationConfigComponent.cs @@ -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 index 000000000..443ccd87a --- /dev/null +++ b/Questionable/Windows/ConfigComponents/SinglePlayerDutyConfigComponent.cs @@ -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 _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> _startingCityBattles = ImmutableDictionary>.Empty; + private ImmutableDictionary> _mainScenarioBattles = ImmutableDictionary>.Empty; + private ImmutableDictionary> _jobQuestBattles = ImmutableDictionary>.Empty; + private ImmutableDictionary> _roleQuestBattles = ImmutableDictionary>.Empty; + private ImmutableList _otherRoleQuestBattles = ImmutableList.Empty; + private ImmutableList<(string Label, List)> _otherQuestBattles = ImmutableList<(string Label, List)>.Empty; + + public SinglePlayerDutyConfigComponent( + IDalamudPluginInterface pluginInterface, + Configuration configuration, + TerritoryData territoryData, + QuestRegistry questRegistry, + QuestData questData, + IDataManager dataManager, + ILogger logger) + : base(pluginInterface, configuration) + { + _territoryData = territoryData; + _questRegistry = questRegistry; + _questData = questData; + _dataManager = dataManager; + _logger = logger; + } + + public void Reload() + { + List questsWithMultipleBattles = _territoryData.GetAllQuestsWithQuestBattles() + .GroupBy(x => x.QuestId) + .Where(x => x.Count() > 1) + .Select(x => x.Key) + .ToList(); + + List mainScenarioBattles = []; + Dictionary> startingCityBattles = + new() + { + { EAetheryteLocation.Limsa, [] }, + { EAetheryteLocation.Gridania, [] }, + { EAetheryteLocation.Uldah, [] }, + }; + + List otherBattles = []; + + Dictionary questIdsToJob = Enum.GetValues() + .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> jobQuestBattles = questIdsToJob.Values.Distinct() + .ToDictionary(x => x, _ => new List()); + + Dictionary> 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> roleQuestBattles = RoleQuestCategories + .ToDictionary(x => x.ClassJob, _ => new List()); + List 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().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()) + { + 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()) + { + 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 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); +} diff --git a/Questionable/Windows/ConfigWindow.cs b/Questionable/Windows/ConfigWindow.cs index e2ac6c314..f3611a5bb 100644 --- a/Questionable/Windows/ConfigWindow.cs +++ b/Questionable/Windows/ConfigWindow.cs @@ -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(); } diff --git a/vendor/pictomancy b/vendor/pictomancy index d147acc0e..70c0e31aa 160000 --- a/vendor/pictomancy +++ b/vendor/pictomancy @@ -1 +1 @@ -Subproject commit d147acc0ea5eed00e25b12508bf5d3fb8eefed53 +Subproject commit 70c0e31aabfbc7067c5b57fd02ee0c72ebc7a22e