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)