using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin; using Dalamud.Utility; using Microsoft.Extensions.Logging; using Questionable.Controller; using Questionable.Data; using Questionable.Functions; using Questionable.Model; using Questionable.Model.Questing; using Questionable.Validation; using Questionable.Windows.QuestComponents; namespace Questionable.Windows.JournalComponents; internal sealed class QuestJournalComponent { private sealed record FilteredSection(JournalData.Section Section, List Categories); private sealed record FilteredCategory(JournalData.Category Category, List Genres); private sealed record FilteredGenre(JournalData.Genre Genre, List Quests); private sealed record JournalCounts(int Available, int Total, int Obtainable, int Completed) { public JournalCounts() : this(0, 0, 0, 0) { } } internal sealed class FilterConfiguration { public string SearchText = string.Empty; public bool AvailableOnly; public bool HideNoPaths; public bool HideUnobtainable; public bool AdvancedFiltersActive { get { if (!AvailableOnly && !HideNoPaths) { return HideUnobtainable; } return true; } } public FilterConfiguration WithoutName() { return new FilterConfiguration { AvailableOnly = AvailableOnly, HideNoPaths = HideNoPaths, HideUnobtainable = HideUnobtainable }; } } private readonly Dictionary _genreCounts = new Dictionary(); private readonly Dictionary _categoryCounts = new Dictionary(); private readonly Dictionary _sectionCounts = new Dictionary(); private readonly JournalData _journalData; private readonly QuestRegistry _questRegistry; private readonly QuestFunctions _questFunctions; private readonly UiUtils _uiUtils; private readonly QuestTooltipComponent _questTooltipComponent; private readonly IDalamudPluginInterface _pluginInterface; private readonly QuestJournalUtils _questJournalUtils; private readonly QuestValidator _questValidator; private readonly Configuration _configuration; private readonly ILogger _logger; private const uint SeasonalJournalCategoryRowId = 96u; private List _filteredSections = new List(); private bool _lastHideSeasonalGlobally; internal FilterConfiguration Filter { get; } = new FilterConfiguration(); public QuestJournalComponent(JournalData journalData, QuestRegistry questRegistry, QuestFunctions questFunctions, UiUtils uiUtils, QuestTooltipComponent questTooltipComponent, IDalamudPluginInterface pluginInterface, QuestJournalUtils questJournalUtils, QuestValidator questValidator, Configuration configuration, ILogger logger) { _journalData = journalData; _questRegistry = questRegistry; _questFunctions = questFunctions; _uiUtils = uiUtils; _questTooltipComponent = questTooltipComponent; _pluginInterface = pluginInterface; _questJournalUtils = questJournalUtils; _questValidator = questValidator; _configuration = configuration; _logger = logger; _lastHideSeasonalGlobally = _configuration.General.HideSeasonalEventsFromJournalProgress; } public void DrawQuests() { using ImRaii.IEndObject endObject = ImRaii.TabItem("Quests"); if (!endObject) { return; } bool hideSeasonalEventsFromJournalProgress = _configuration.General.HideSeasonalEventsFromJournalProgress; if (hideSeasonalEventsFromJournalProgress != _lastHideSeasonalGlobally) { _lastHideSeasonalGlobally = hideSeasonalEventsFromJournalProgress; _logger.LogDebug("Configuration change detected: HideSeasonalEventsFromJournalProgress={Hide} - refreshing journal", hideSeasonalEventsFromJournalProgress); UpdateFilter(); } if (ImGui.CollapsingHeader("Explanation", ImGuiTreeNodeFlags.DefaultOpen)) { ImGui.Text("The list below contains all quests that appear in your journal."); ImGui.BulletText("'Supported' lists quests that Questionable can do for you"); ImGui.BulletText("'Completed' lists quests your current character has completed."); ImGui.BulletText("Not all quests can be completed even if they're listed as available, e.g. starting city quest chains or past seasonal events."); ImGui.Spacing(); ImGui.Separator(); ImGui.Spacing(); } QuestJournalUtils.ShowFilterContextMenu(this); ImGui.SameLine(); if (ImGuiComponents.IconButton(FontAwesomeIcon.GlobeEurope)) { Util.OpenLink("https://wigglymuffin.github.io/FFXIV-Tools/"); } if (ImGui.IsItemHovered()) { ImGui.SetTooltip("View All Quest Paths Online"); } ImGui.SameLine(); ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); if (ImGui.InputTextWithHint(string.Empty, "Search quests and categories", ref Filter.SearchText, 256)) { UpdateFilter(); } if (_filteredSections.Count > 0) { using (ImRaii.IEndObject endObject2 = ImRaii.Table("Quests", 3, ImGuiTableFlags.NoSavedSettings)) { if (!endObject2) { return; } ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.NoHide); ImGui.TableSetupColumn("Supported", ImGuiTableColumnFlags.WidthFixed, 120f * ImGui.GetIO().FontGlobalScale); ImGui.TableSetupColumn("Completed", ImGuiTableColumnFlags.WidthFixed, 120f * ImGui.GetIO().FontGlobalScale); ImGui.TableHeadersRow(); foreach (FilteredSection filteredSection in _filteredSections) { DrawSection(filteredSection); } return; } } ImGui.Text("No quest or category matches your search."); } private void DrawSection(FilteredSection filter) { var (count, num5, total, count2) = _sectionCounts.GetValueOrDefault(filter.Section, new JournalCounts()); if (num5 == 0) { return; } ImGui.TableNextRow(); ImGui.TableNextColumn(); bool num6 = ImGui.TreeNodeEx(filter.Section.Name, ImGuiTreeNodeFlags.SpanFullWidth); ImGui.TableNextColumn(); DrawCount(count, num5); ImGui.TableNextColumn(); DrawCount(count2, total); if (!num6) { return; } foreach (FilteredCategory category in filter.Categories) { DrawCategory(category); } ImGui.TreePop(); } private void DrawCategory(FilteredCategory filter) { var (count, num5, total, count2) = _categoryCounts.GetValueOrDefault(filter.Category, new JournalCounts()); if (num5 == 0) { return; } ImGui.TableNextRow(); ImGui.TableNextColumn(); bool num6 = ImGui.TreeNodeEx(filter.Category.Name, ImGuiTreeNodeFlags.SpanFullWidth); ImGui.TableNextColumn(); DrawCount(count, num5); ImGui.TableNextColumn(); DrawCount(count2, total); if (!num6) { return; } foreach (FilteredGenre genre in filter.Genres) { DrawGenre(genre); } ImGui.TreePop(); } private void DrawGenre(FilteredGenre filter) { var (count, num5, total, count2) = _genreCounts.GetValueOrDefault(filter.Genre, new JournalCounts()); if (num5 == 0) { return; } ImGui.TableNextRow(); ImGui.TableNextColumn(); bool num6 = ImGui.TreeNodeEx(filter.Genre.Name, ImGuiTreeNodeFlags.SpanFullWidth); ImGui.TableNextColumn(); DrawCount(count, num5); ImGui.TableNextColumn(); DrawCount(count2, total); if (!num6) { return; } foreach (IQuestInfo quest in filter.Quests) { DrawQuest(quest); } ImGui.TreePop(); } private void DrawQuest(IQuestInfo questInfo) { _questRegistry.TryGetQuest(questInfo.QuestId, out Quest quest); ImGui.TableNextRow(); ImGui.TableNextColumn(); DrawQuestName(questInfo, out var expansionHovered); DrawQuestTooltipAndContextMenu(questInfo, quest, expansionHovered); ImGui.TableNextColumn(); DrawQuestSupportStatus(questInfo, quest); ImGui.TableNextColumn(); DrawQuestCompletionStatus(questInfo); } private static void DrawQuestName(IQuestInfo questInfo, out bool expansionHovered) { expansionHovered = false; if (questInfo.Expansion != (EExpansionVersion)255) { ImGui.PushFont(UiBuilder.MonoFont); ImGui.PushStyleColor(ImGuiCol.Text, questInfo.Expansion.GetExpansionColor()); ImGui.TextUnformatted(questInfo.Expansion.ToAbbreviation().PadRight(3)); ImGui.PopStyleColor(); ImGui.PopFont(); if (ImGui.IsItemHovered()) { expansionHovered = true; } ImGui.SameLine(); } ImU8String id = new ImU8String(3, 2); id.AppendFormatted(questInfo.Name); id.AppendLiteral(" ("); id.AppendFormatted(questInfo.QuestId); id.AppendLiteral(")"); ImGui.TreeNodeEx(id, ImGuiTreeNodeFlags.NoTreePushOnOpen | ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.SpanFullWidth); } private void DrawQuestTooltipAndContextMenu(IQuestInfo questInfo, Quest? quest, bool expansionHovered) { if ((ImGui.IsItemHovered() || expansionHovered) && questInfo.Expansion != (EExpansionVersion)255) { ImGui.BeginTooltip(); ImGui.PushStyleColor(ImGuiCol.Text, questInfo.Expansion.GetExpansionColor()); ImGui.TextUnformatted(questInfo.Expansion.ToFriendlyString()); ImGui.PopStyleColor(); ImGui.Separator(); _questTooltipComponent.Draw(questInfo); ImGui.EndTooltip(); } else if (ImGui.IsItemHovered() && questInfo.Expansion == (EExpansionVersion)255) { _questTooltipComponent.Draw(questInfo); } _questJournalUtils.ShowContextMenu(questInfo, quest, "QuestJournalComponent"); } private void DrawQuestSupportStatus(IQuestInfo questInfo, Quest? quest) { float num; using (_pluginInterface.UiBuilder.IconFontFixedWidthHandle.Push()) { num = ImGui.GetColumnWidth() / 2f - ImGui.CalcTextSize(FontAwesomeIcon.Check.ToIconString()).X; } ImGui.SetCursorPosX(ImGui.GetCursorPosX() + num); if (_questFunctions.IsQuestRemoved(questInfo.QuestId)) { _uiUtils.ChecklistItem(string.Empty, ImGuiColors.DalamudGrey, FontAwesomeIcon.Minus); return; } if (quest != null) { QuestRoot root = quest.Root; if (root != null && !root.Disabled) { List issues = _questValidator.GetIssues(quest.Id); if (issues.Any((ValidationIssue x) => x.Severity == EIssueSeverity.Error)) { _uiUtils.ChecklistItem(string.Empty, ImGuiColors.DalamudRed, FontAwesomeIcon.ExclamationTriangle); } else if (issues.Count > 0) { _uiUtils.ChecklistItem(string.Empty, ImGuiColors.ParsedBlue, FontAwesomeIcon.InfoCircle); } else { _uiUtils.ChecklistItem(string.Empty, complete: true); } return; } } _uiUtils.ChecklistItem(string.Empty, complete: false); } private void DrawQuestCompletionStatus(IQuestInfo questInfo) { if (_questFunctions.IsQuestAccepted(questInfo.QuestId)) { _uiUtils.ChecklistItem("Active", ImGuiColors.DalamudYellow, FontAwesomeIcon.PersonWalkingArrowRight); } else if (_questFunctions.IsQuestComplete(questInfo.QuestId)) { DrawCompletedQuestStatus(questInfo); } else { DrawIncompleteQuestStatus(questInfo); } } private void DrawCompletedQuestStatus(IQuestInfo questInfo) { if (!questInfo.IsRepeatable) { _uiUtils.ChecklistItem("Complete", ImGuiColors.ParsedGreen, FontAwesomeIcon.Check); return; } bool num = _questFunctions.IsQuestLocked(questInfo.QuestId); bool flag = _questFunctions.IsReadyToAcceptQuest(questInfo.QuestId); if (!num && flag) { _uiUtils.ChecklistItem("Available", ImGuiColors.ParsedBlue, FontAwesomeIcon.Running); } else { _uiUtils.ChecklistItem("Complete", ImGuiColors.ParsedGreen, FontAwesomeIcon.Check); } } private void DrawIncompleteQuestStatus(IQuestInfo questInfo) { bool num = IsQuestExpired(questInfo); bool flag = _questFunctions.IsQuestUnobtainable(questInfo.QuestId); bool flag2 = _questFunctions.IsQuestLocked(questInfo.QuestId); bool flag3 = _questFunctions.IsReadyToAcceptQuest(questInfo.QuestId); if (num || flag) { _uiUtils.ChecklistItem("Unobtainable", ImGuiColors.DalamudGrey, FontAwesomeIcon.Minus); } else if (flag2 || !flag3 || !_questRegistry.IsKnownQuest(questInfo.QuestId)) { _uiUtils.ChecklistItem("Locked", ImGuiColors.DalamudRed, FontAwesomeIcon.Times); } else { _uiUtils.ChecklistItem("Available", ImGuiColors.DalamudYellow, FontAwesomeIcon.Running); } } private static bool IsQuestExpired(IQuestInfo questInfo) { DateTime? seasonalQuestExpiry = questInfo.SeasonalQuestExpiry; if (seasonalQuestExpiry.HasValue) { DateTime valueOrDefault = seasonalQuestExpiry.GetValueOrDefault(); DateTime dateTime = ((valueOrDefault.Kind == DateTimeKind.Utc) ? valueOrDefault : valueOrDefault.ToUniversalTime()); return DateTime.UtcNow > dateTime; } return false; } private static void DrawCount(int count, int total) { string text = 9999.ToString(CultureInfo.CurrentCulture); ImGui.PushFont(UiBuilder.MonoFont); if (total == 0) { Vector4 col = ImGuiColors.DalamudGrey; ImU8String text2 = new ImU8String(3, 2); text2.AppendFormatted("-".PadLeft(text.Length)); text2.AppendLiteral(" / "); text2.AppendFormatted("-".PadLeft(text.Length)); ImGui.TextColored(in col, text2); } else { string text3 = count.ToString(CultureInfo.CurrentCulture).PadLeft(text.Length) + " / " + total.ToString(CultureInfo.CurrentCulture).PadLeft(text.Length); if (count == total) { ImGui.TextColored(ImGuiColors.ParsedGreen, text3); } else { ImGui.TextUnformatted(text3); } } ImGui.PopFont(); } public void UpdateFilter() { _filteredSections = (from x in _journalData.Sections select FilterSection(x, Filter) into x where x.Categories.Count > 0 select x).ToList(); RefreshCounts(); } private FilteredSection FilterSection(JournalData.Section section, FilterConfiguration filter) { IEnumerable enumerable; if (!_configuration.General.HideSeasonalEventsFromJournalProgress) { IEnumerable categories = section.Categories; enumerable = categories; } else { enumerable = section.Categories.Where((JournalData.Category c) => c.Id != 96); } IEnumerable source = enumerable; return new FilteredSection(Categories: ((!IsCategorySectionGenreMatch(filter, section.Name)) ? source.Select((JournalData.Category category) => FilterCategory(category, filter, section)) : source.Select((JournalData.Category x) => FilterCategory(x, filter.WithoutName(), section))).Where((FilteredCategory x) => x.Genres.Count > 0).ToList(), Section: section); } private FilteredCategory FilterCategory(JournalData.Category category, FilterConfiguration filter, JournalData.Section? parentSection = null) { IEnumerable source = ((!IsCategorySectionGenreMatch(filter, category.Name)) ? category.Genres.Select((JournalData.Genre genre) => FilterGenre(genre, filter, parentSection)) : category.Genres.Select((JournalData.Genre x) => FilterGenre(x, filter.WithoutName(), parentSection))); return new FilteredCategory(category, source.Where((FilteredGenre x) => x.Quests.Count > 0).ToList()); } private FilteredGenre FilterGenre(JournalData.Genre genre, FilterConfiguration filter, JournalData.Section? parentSection = null) { bool hideSeasonalEventsFromJournalProgress = _configuration.General.HideSeasonalEventsFromJournalProgress; IEnumerable source = ((!IsCategorySectionGenreMatch(filter, genre.Name)) ? genre.Quests.Where((IQuestInfo x) => IsQuestMatch(filter, x)) : genre.Quests.Where((IQuestInfo x) => IsQuestMatch(filter.WithoutName(), x))); if (hideSeasonalEventsFromJournalProgress && genre.CategoryId == 96) { source = source.Where((IQuestInfo q) => !IsSeasonal(q)); } return new FilteredGenre(genre, source.ToList()); } internal void RefreshCounts() { _genreCounts.Clear(); _categoryCounts.Clear(); _sectionCounts.Clear(); bool hideSeasonalEventsFromJournalProgress = _configuration.General.HideSeasonalEventsFromJournalProgress; _logger.LogInformation("Refreshing journal counts. HideSeasonalEventsFromJournalProgress={Hide}", hideSeasonalEventsFromJournalProgress); foreach (JournalData.Genre genre in _journalData.Genres) { List source = ((hideSeasonalEventsFromJournalProgress && genre.CategoryId == 96) ? genre.Quests.Where((IQuestInfo q) => !IsSeasonal(q)).ToList() : genre.Quests.ToList()); Quest quest; int available = source.Count((IQuestInfo x) => _questRegistry.TryGetQuest(x.QuestId, out quest) && !quest.Root.Disabled && !_questFunctions.IsQuestRemoved(x.QuestId)); int total = source.Count((IQuestInfo x) => !_questFunctions.IsQuestRemoved(x.QuestId)); int obtainable = source.Count((IQuestInfo x) => !_questFunctions.IsQuestUnobtainable(x.QuestId)); int completed = source.Count((IQuestInfo x) => _questFunctions.IsQuestComplete(x.QuestId)); _genreCounts[genre] = new JournalCounts(available, total, obtainable, completed); } foreach (JournalData.Category category in _journalData.Categories) { if (!hideSeasonalEventsFromJournalProgress || category.Id != 96) { List source2 = _genre_counts_or_default(category); int available2 = source2.Sum((JournalCounts x) => x.Available); int total2 = source2.Sum((JournalCounts x) => x.Total); int obtainable2 = source2.Sum((JournalCounts x) => x.Obtainable); int completed2 = source2.Sum((JournalCounts x) => x.Completed); _categoryCounts[category] = new JournalCounts(available2, total2, obtainable2, completed2); } } foreach (JournalData.Section section in _journalData.Sections) { List source3 = (from x in _categoryCounts where section.Categories.Contains(x.Key) select x.Value).ToList(); int available3 = source3.Sum((JournalCounts x) => x.Available); int total3 = source3.Sum((JournalCounts x) => x.Total); int obtainable3 = source3.Sum((JournalCounts x) => x.Obtainable); int completed3 = source3.Sum((JournalCounts x) => x.Completed); _sectionCounts[section] = new JournalCounts(available3, total3, obtainable3, completed3); } int num = _sectionCounts.Values.Sum((JournalCounts x) => x.Total); _logger.LogDebug("RefreshCounts complete. Sections={Sections}, Categories={Categories}, Genres={Genres}, TotalQuests={Total}", _sectionCounts.Count, _categoryCounts.Count, _genreCounts.Count, num); } private List _genre_counts_or_default(JournalData.Category category) { return (from x in _genreCounts where category.Genres.Contains(x.Key) select x.Value).ToList(); } internal void ClearCounts(int type, int code) { foreach (KeyValuePair item in _genreCounts.ToList()) { _genreCounts[item.Key] = item.Value with { Completed = 0 }; } foreach (KeyValuePair item2 in _categoryCounts.ToList()) { _categoryCounts[item2.Key] = item2.Value with { Completed = 0 }; } foreach (KeyValuePair item3 in _sectionCounts.ToList()) { _sectionCounts[item3.Key] = item3.Value with { Completed = 0 }; } } private static bool IsCategorySectionGenreMatch(FilterConfiguration filter, string name) { if (!string.IsNullOrEmpty(filter.SearchText)) { return name.Contains(filter.SearchText, StringComparison.CurrentCultureIgnoreCase); } return true; } private bool IsQuestMatch(FilterConfiguration filter, IQuestInfo questInfo) { if (!string.IsNullOrEmpty(filter.SearchText) && !questInfo.Name.Contains(filter.SearchText, StringComparison.CurrentCultureIgnoreCase) && !(questInfo.QuestId.ToString() == filter.SearchText)) { return false; } if (filter.AvailableOnly && !_questFunctions.IsReadyToAcceptQuest(questInfo.QuestId)) { return false; } if (filter.HideNoPaths && (!_questRegistry.TryGetQuest(questInfo.QuestId, out Quest quest) || quest.Root.Disabled)) { return false; } if (filter.HideUnobtainable && _questFunctions.IsQuestUnobtainable(questInfo.QuestId)) { return false; } return true; } private static bool IsSeasonal(IQuestInfo q) { if (q == null) { return false; } if (q.IsSeasonalQuest) { return true; } if (q.SeasonalQuestExpiry.HasValue) { return true; } if (q is UnlockLinkQuestInfo { QuestExpiry: not null }) { return true; } return false; } }