--- /dev/null
+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);
+}