From c722abb6dfd479e8d38e9dda10d71571d1b3d3cb Mon Sep 17 00:00:00 2001 From: Liza Carvelli Date: Thu, 2 Jan 2025 22:50:50 +0100 Subject: [PATCH] Automatically redeem untradeable mounts/minions/orchestrion rolls/TT cards/fashion accessories from quest rewards --- Questionable.sln.DotSettings | 1 + .../Steps/Shared/RedeemRewardItems.cs | 71 ++++++++++++ Questionable/Data/QuestData.cs | 8 ++ Questionable/Model/ItemReward.cs | 99 +++++++++++++++++ Questionable/Model/QuestInfo.cs | 17 ++- Questionable/QuestionablePlugin.cs | 9 +- .../JournalComponents/QuestRewardComponent.cs | 65 +++++++++++ Questionable/Windows/JournalProgressWindow.cs | 4 + .../QuestComponents/QuestTooltipComponent.cs | 105 +++++++++++------- 9 files changed, 332 insertions(+), 47 deletions(-) create mode 100644 Questionable/Controller/Steps/Shared/RedeemRewardItems.cs create mode 100644 Questionable/Model/ItemReward.cs create mode 100644 Questionable/Windows/JournalComponents/QuestRewardComponent.cs diff --git a/Questionable.sln.DotSettings b/Questionable.sln.DotSettings index 830879e0..ef6a1abd 100644 --- a/Questionable.sln.DotSettings +++ b/Questionable.sln.DotSettings @@ -25,6 +25,7 @@ True True True + True True True True diff --git a/Questionable/Controller/Steps/Shared/RedeemRewardItems.cs b/Questionable/Controller/Steps/Shared/RedeemRewardItems.cs new file mode 100644 index 00000000..4d11e72b --- /dev/null +++ b/Questionable/Controller/Steps/Shared/RedeemRewardItems.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using Questionable.Data; +using Questionable.Functions; +using Questionable.Model; +using Questionable.Model.Questing; + +namespace Questionable.Controller.Steps.Shared; + +internal static class RedeemRewardItems +{ + internal sealed class Factory(QuestData questData) : ITaskFactory + { + public IEnumerable CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step) + { + if (step.InteractionType != EInteractionType.AcceptQuest) + return []; + + List tasks = []; + unsafe + { + InventoryManager* inventoryManager = InventoryManager.Instance(); + if (inventoryManager == null) + return tasks; + + foreach (var itemReward in questData.RedeemableItems) + { + if (inventoryManager->GetInventoryItemCount(itemReward.ItemId) > 0 && + !itemReward.IsUnlocked()) + { + tasks.Add(new Task(itemReward)); + } + } + } + + return tasks; + } + } + + internal sealed record Task(ItemReward ItemReward) : ITask + { + public override string ToString() => $"TryRedeem({ItemReward.Name})"; + } + + internal sealed class Executor( + GameFunctions gameFunctions, + ICondition condition) : TaskExecutor + { + private DateTime _continueAt; + + protected override bool Start() + { + if (condition[ConditionFlag.Mounted]) + return false; + + _continueAt = DateTime.Now.Add(Task.ItemReward.CastTime).AddSeconds(1); + return gameFunctions.UseItem(Task.ItemReward.ItemId); + } + + public override ETaskResult Update() + { + if (condition[ConditionFlag.Casting]) + return ETaskResult.StillRunning; + + return DateTime.Now <= _continueAt ? ETaskResult.StillRunning : ETaskResult.TaskComplete; + } + } +} diff --git a/Questionable/Data/QuestData.cs b/Questionable/Data/QuestData.cs index 8f51c499..7a3229a7 100644 --- a/Questionable/Data/QuestData.cs +++ b/Questionable/Data/QuestData.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Linq; using Dalamud.Plugin.Services; @@ -218,8 +219,15 @@ internal sealed class QuestData quest.JournalGenre = 82; quest.SortKey = 0; } + + RedeemableItems = quests.Where(x => x is QuestInfo) + .Cast() + .SelectMany(x => x.ItemRewards) + .ToImmutableHashSet(); } + public ImmutableHashSet RedeemableItems { get; } + private void AddPreviousQuest(QuestId questToUpdate, QuestId requiredQuestId) { QuestInfo quest = (QuestInfo)_quests[questToUpdate]; diff --git a/Questionable/Model/ItemReward.cs b/Questionable/Model/ItemReward.cs new file mode 100644 index 00000000..03044744 --- /dev/null +++ b/Questionable/Model/ItemReward.cs @@ -0,0 +1,99 @@ +using System; +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using Lumina.Excel.Sheets; +using Questionable.Model.Questing; + +namespace Questionable.Model; + +public enum EItemRewardType +{ + Mount, + Minion, + OrchestrionRoll, + TripleTriadCard, + FashionAccessory, +} + +public sealed class ItemRewardDetails(Item item, ElementId elementId) +{ + public uint ItemId { get; } = item.RowId; + public string Name { get; } = item.Name.ToDalamudString().ToString(); + public TimeSpan CastTime { get; } = TimeSpan.FromSeconds(item.CastTimeSeconds); + public ElementId ElementId { get; } = elementId; +} + +public abstract record ItemReward(ItemRewardDetails Item) +{ + internal static ItemReward? CreateFromItem(Item item, ElementId elementId) + { + if (item.ItemAction.ValueNullable?.Type is 1322) + return new MountReward(new ItemRewardDetails(item, elementId), item.ItemAction.Value.Data[0]); + + if (item.ItemAction.ValueNullable?.Type is 853) + return new MinionReward(new ItemRewardDetails(item, elementId), item.ItemAction.Value.Data[0]); + + if (item.AdditionalData.GetValueOrDefault() is { } orchestrionRoll) + return new OrchestrionRollReward(new ItemRewardDetails(item, elementId), orchestrionRoll.RowId); + + if (item.AdditionalData.GetValueOrDefault() is { } tripleTriadCard) + return new TripleTriadCardReward(new ItemRewardDetails(item, elementId), (ushort)tripleTriadCard.RowId); + + if (item.ItemAction.ValueNullable?.Type is 20086) + return new FashionAccessoryReward(new ItemRewardDetails(item, elementId), item.ItemAction.Value.Data[0]); + + return null; + } + + public uint ItemId => Item.ItemId; + public string Name => Item.Name; + public ElementId ElementId => Item.ElementId; + public TimeSpan CastTime => Item.CastTime; + public abstract EItemRewardType Type { get; } + public abstract bool IsUnlocked(); +} + +public sealed record MountReward(ItemRewardDetails Item, uint MountId) + : ItemReward(Item) +{ + public override EItemRewardType Type => EItemRewardType.Mount; + + public override unsafe bool IsUnlocked() + => PlayerState.Instance()->IsMountUnlocked(MountId); +} + +public sealed record MinionReward(ItemRewardDetails Item, uint MinionId) + : ItemReward(Item) +{ + public override EItemRewardType Type => EItemRewardType.Minion; + + public override unsafe bool IsUnlocked() + => UIState.Instance()->IsCompanionUnlocked(MinionId); +} + +public sealed record OrchestrionRollReward(ItemRewardDetails Item, uint OrchestrionRollId) + : ItemReward(Item) +{ + public override EItemRewardType Type => EItemRewardType.OrchestrionRoll; + + public override unsafe bool IsUnlocked() => + PlayerState.Instance()->IsOrchestrionRollUnlocked(OrchestrionRollId); +} + +public sealed record TripleTriadCardReward(ItemRewardDetails Item, ushort TripleTriadCardId) + : ItemReward(Item) +{ + public override EItemRewardType Type => EItemRewardType.TripleTriadCard; + + public override unsafe bool IsUnlocked() => + UIState.Instance()->IsTripleTriadCardUnlocked(TripleTriadCardId); +} + +public sealed record FashionAccessoryReward(ItemRewardDetails Item, uint AccessoryId) + : ItemReward(Item) +{ + public override EItemRewardType Type => EItemRewardType.FashionAccessory; + + public override unsafe bool IsUnlocked() => + PlayerState.Instance()->IsOrnamentUnlocked(AccessoryId); +} diff --git a/Questionable/Model/QuestInfo.cs b/Questionable/Model/QuestInfo.cs index e34c16d7..06c6dc34 100644 --- a/Questionable/Model/QuestInfo.cs +++ b/Questionable/Model/QuestInfo.cs @@ -2,10 +2,11 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using FFXIVClientStructs.FFXIV.Client.UI.Agent; using LLib.GameData; +using Lumina.Excel.Sheets; using Questionable.Model.Questing; using ExcelQuest = Lumina.Excel.Sheets.Quest; +using GrandCompany = FFXIVClientStructs.FFXIV.Client.UI.Agent.GrandCompany; namespace Questionable.Model; @@ -54,7 +55,8 @@ internal sealed class QuestInfo : IQuestInfo QuestLockJoin = (EQuestJoin)quest.QuestLockJoin; JournalGenre = quest.JournalGenre.ValueNullable?.RowId; SortKey = quest.SortKey; - IsMainScenarioQuest = quest.JournalGenre.ValueNullable?.JournalCategory.ValueNullable?.JournalSection.ValueNullable?.RowId is 0 or 1; + IsMainScenarioQuest = quest.JournalGenre.ValueNullable?.JournalCategory.ValueNullable?.JournalSection + .ValueNullable?.RowId is 0 or 1; CompletesInstantly = quest.TodoParams[0].ToDoCompleteSeq == 0; PreviousInstanceContent = quest.InstanceContent.Select(x => (ushort)x.RowId).Where(x => x != 0).ToList(); PreviousInstanceContentJoin = (EQuestJoin)quest.InstanceContentJoin; @@ -67,6 +69,15 @@ internal sealed class QuestInfo : IQuestInfo NewGamePlusChapter = newGamePlusChapter; StartingCity = startingCity; MoogleDeliveryLevel = (byte)quest.DeliveryQuest.RowId; + ItemRewards = quest.Reward.Where(x => x.RowId > 0 && x.Is()) + .Select(x => x.GetValueOrDefault()) + .Where(x => x != null) + .Cast() + .Where(x => x.IsUntradable) + .Select(x => ItemReward.CreateFromItem(x, QuestId)) + .Where(x => x != null) + .Cast() + .ToList(); Expansion = (EExpansionVersion)quest.Expansion.RowId; } @@ -79,7 +90,6 @@ internal sealed class QuestInfo : IQuestInfo }); } - public ElementId QuestId { get; } public string Name { get; } public ushort Level { get; } @@ -105,6 +115,7 @@ internal sealed class QuestInfo : IQuestInfo public byte StartingCity { get; set; } public byte MoogleDeliveryLevel { get; } public bool IsMoogleDeliveryQuest => JournalGenre == 87; + public IReadOnlyList ItemRewards { get; } public EExpansionVersion Expansion { get; } public void AddPreviousQuest(PreviousQuestInfo questId) diff --git a/Questionable/QuestionablePlugin.cs b/Questionable/QuestionablePlugin.cs index 1072a54f..f5fa51ee 100644 --- a/Questionable/QuestionablePlugin.cs +++ b/Questionable/QuestionablePlugin.cs @@ -138,6 +138,8 @@ public sealed class QuestionablePlugin : IDalamudPlugin serviceCollection.AddTaskFactory(); serviceCollection .AddTaskExecutor(); + serviceCollection + .AddTaskFactoryAndExecutor(); serviceCollection.AddTaskExecutor(); serviceCollection.AddTaskExecutor(); serviceCollection.AddTaskFactoryAndExecutor(); serviceCollection - .AddTaskExecutor(); + .AddTaskExecutor(); serviceCollection .AddTaskFactoryAndExecutor(); serviceCollection.AddTaskFactoryAndExecutor(); @@ -179,7 +182,8 @@ public sealed class QuestionablePlugin : IDalamudPlugin .AddTaskFactoryAndExecutor(); serviceCollection.AddTaskFactoryAndExecutor(); serviceCollection.AddTaskFactoryAndExecutor(); - serviceCollection.AddTaskFactoryAndExecutor(); + serviceCollection + .AddTaskFactoryAndExecutor(); serviceCollection.AddTaskExecutor(); serviceCollection.AddTaskExecutor(); serviceCollection.AddTaskFactory(); @@ -269,6 +273,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Questionable/Windows/JournalComponents/QuestRewardComponent.cs b/Questionable/Windows/JournalComponents/QuestRewardComponent.cs new file mode 100644 index 00000000..2fab3109 --- /dev/null +++ b/Questionable/Windows/JournalComponents/QuestRewardComponent.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using Dalamud.Interface.Utility.Raii; +using ImGuiNET; +using Questionable.Data; +using Questionable.Model; +using Questionable.Windows.QuestComponents; + +namespace Questionable.Windows.JournalComponents; + +internal sealed class QuestRewardComponent +{ + private readonly QuestData _questData; + private readonly QuestTooltipComponent _questTooltipComponent; + private readonly UiUtils _uiUtils; + + public QuestRewardComponent( + QuestData questData, + QuestTooltipComponent questTooltipComponent, + UiUtils uiUtils) + { + _questData = questData; + _questTooltipComponent = questTooltipComponent; + _uiUtils = uiUtils; + } + + public void DrawItemRewards() + { + using var tab = ImRaii.TabItem("Item Rewards"); + if (!tab) + return; + + ImGui.BulletText("Only untradeable items are listed (you can e.g. sell your Wind-up Airship from the enovy quest)."); + + DrawGroup("Mounts", EItemRewardType.Mount); + DrawGroup("Minions", EItemRewardType.Minion); + DrawGroup("Orchestrion Rolls", EItemRewardType.OrchestrionRoll); + DrawGroup("Triple Triad Cards", EItemRewardType.TripleTriadCard); + DrawGroup("Fashion Accessories", EItemRewardType.FashionAccessory); + } + + private void DrawGroup(string label, EItemRewardType type) + { + if (!ImGui.CollapsingHeader($"{label}###Reward{type}")) + return; + + foreach (var item in _questData.RedeemableItems.Where(x => x.Type == type) + .OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase)) + { + if (_uiUtils.ChecklistItem(item.Name, item.IsUnlocked())) + { + if (_questData.TryGetQuestInfo(item.ElementId, out var questInfo)) + { + using var tooltip = ImRaii.Tooltip(); + if (!tooltip) + continue; + + ImGui.Text($"Obtained from: {questInfo.Name}"); + using (ImRaii.PushIndent()) + _questTooltipComponent.DrawInner(questInfo, false); + } + } + } + } +} diff --git a/Questionable/Windows/JournalProgressWindow.cs b/Questionable/Windows/JournalProgressWindow.cs index 6a5724a7..e6d2a16d 100644 --- a/Questionable/Windows/JournalProgressWindow.cs +++ b/Questionable/Windows/JournalProgressWindow.cs @@ -12,12 +12,14 @@ internal sealed class JournalProgressWindow : LWindow, IDisposable { private readonly QuestJournalComponent _questJournalComponent; private readonly AlliedSocietyJournalComponent _alliedSocietyJournalComponent; + private readonly QuestRewardComponent _questRewardComponent; private readonly GatheringJournalComponent _gatheringJournalComponent; private readonly QuestRegistry _questRegistry; private readonly IClientState _clientState; public JournalProgressWindow( QuestJournalComponent questJournalComponent, + QuestRewardComponent questRewardComponent, AlliedSocietyJournalComponent alliedSocietyJournalComponent, GatheringJournalComponent gatheringJournalComponent, QuestRegistry questRegistry, @@ -26,6 +28,7 @@ internal sealed class JournalProgressWindow : LWindow, IDisposable { _questJournalComponent = questJournalComponent; _alliedSocietyJournalComponent = alliedSocietyJournalComponent; + _questRewardComponent = questRewardComponent; _gatheringJournalComponent = gatheringJournalComponent; _questRegistry = questRegistry; _clientState = clientState; @@ -64,6 +67,7 @@ internal sealed class JournalProgressWindow : LWindow, IDisposable _questJournalComponent.DrawQuests(); _alliedSocietyJournalComponent.DrawAlliedSocietyQuests(); + _questRewardComponent.DrawItemRewards(); _gatheringJournalComponent.DrawGatheringItems(); } diff --git a/Questionable/Windows/QuestComponents/QuestTooltipComponent.cs b/Questionable/Windows/QuestComponents/QuestTooltipComponent.cs index bcf823ae..e74beb2a 100644 --- a/Questionable/Windows/QuestComponents/QuestTooltipComponent.cs +++ b/Questionable/Windows/QuestComponents/QuestTooltipComponent.cs @@ -40,48 +40,58 @@ internal sealed class QuestTooltipComponent { using var tooltip = ImRaii.Tooltip(); if (tooltip) - { - ImGui.Text($"{SeIconChar.LevelEn.ToIconString()}{questInfo.Level}"); - ImGui.SameLine(); - - var (color, _, tooltipText) = _uiUtils.GetQuestStyle(questInfo.QuestId); - ImGui.TextColored(color, tooltipText); - if (questInfo.IsRepeatable) - { - ImGui.SameLine(); - ImGui.TextUnformatted("Repeatable"); - } - - if (questInfo is QuestInfo { CompletesInstantly: true }) - { - ImGui.SameLine(); - ImGui.TextUnformatted("Instant"); - } - - if (_questRegistry.TryGetQuest(questInfo.QuestId, out Quest? quest)) - { - if (quest.Root.Disabled) - { - ImGui.SameLine(); - ImGui.TextColored(ImGuiColors.DalamudRed, "Disabled"); - } - - if (quest.Root.Author.Count == 1) - ImGui.Text($"Author: {quest.Root.Author[0]}"); - else - ImGui.Text($"Authors: {string.Join(", ", quest.Root.Author)}"); - } - else - { - ImGui.SameLine(); - ImGui.TextColored(ImGuiColors.DalamudRed, "NoQuestPath"); - } - - DrawQuestUnlocks(questInfo, 0); - } + DrawInner(questInfo, true); } - private void DrawQuestUnlocks(IQuestInfo questInfo, int counter) + public void DrawInner(IQuestInfo questInfo, bool showItemRewards) + { + ImGui.Text($"{SeIconChar.LevelEn.ToIconString()}{questInfo.Level}"); + ImGui.SameLine(); + + var (color, _, tooltipText) = _uiUtils.GetQuestStyle(questInfo.QuestId); + ImGui.TextColored(color, tooltipText); + + if (questInfo is QuestInfo { IsSeasonalEvent: true }) + { + ImGui.SameLine(); + ImGui.TextUnformatted("Event"); + } + + if (questInfo.IsRepeatable) + { + ImGui.SameLine(); + ImGui.TextUnformatted("Repeatable"); + } + + if (questInfo is QuestInfo { CompletesInstantly: true }) + { + ImGui.SameLine(); + ImGui.TextUnformatted("Instant"); + } + + if (_questRegistry.TryGetQuest(questInfo.QuestId, out Quest? quest)) + { + if (quest.Root.Disabled) + { + ImGui.SameLine(); + ImGui.TextColored(ImGuiColors.DalamudRed, "Disabled"); + } + + if (quest.Root.Author.Count == 1) + ImGui.Text($"Author: {quest.Root.Author[0]}"); + else + ImGui.Text($"Authors: {string.Join(", ", quest.Root.Author)}"); + } + else + { + ImGui.SameLine(); + ImGui.TextColored(ImGuiColors.DalamudRed, "NoQuestPath"); + } + + DrawQuestUnlocks(questInfo, 0, showItemRewards); + } + + private void DrawQuestUnlocks(IQuestInfo questInfo, int counter, bool showItemRewards) { if (counter >= 10) return; @@ -118,12 +128,13 @@ internal sealed class QuestTooltipComponent _questFunctions.IsQuestComplete(q.QuestId) ? byte.MinValue : q.Sequence), iconColor, icon); if (qInfo is QuestInfo qstInfo && (counter <= 2 || icon != FontAwesomeIcon.Check)) - DrawQuestUnlocks(qstInfo, counter + 1); + DrawQuestUnlocks(qstInfo, counter + 1, false); } else { using var _ = ImRaii.Disabled(); - _uiUtils.ChecklistItem($"Unknown Quest ({q.QuestId})", ImGuiColors.DalamudGrey, FontAwesomeIcon.Question); + _uiUtils.ChecklistItem($"Unknown Quest ({q.QuestId})", ImGuiColors.DalamudGrey, + FontAwesomeIcon.Question); } } } @@ -193,6 +204,16 @@ internal sealed class QuestTooltipComponent GrandCompany currentGrandCompany = _questFunctions.GetGrandCompany(); _uiUtils.ChecklistItem($"Grand Company: {gcName}", actualQuestInfo.GrandCompany == currentGrandCompany); } + + if (showItemRewards && actualQuestInfo.ItemRewards.Count > 0) + { + ImGui.Separator(); + ImGui.Text("Item Rewards:"); + foreach (var reward in actualQuestInfo.ItemRewards) + { + ImGui.BulletText(reward.Name); + } + } } if (counter > 0)