1
0
forked from liza/Questionable
Questionable/Questionable/Functions/QuestFunctions.cs

619 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Dalamud.Memory;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Application.Network.WorkDefinitions;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.Game.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI;
using LLib.GameData;
using LLib.GameUI;
using Lumina.Excel.GeneratedSheets;
using Questionable.Controller;
using Questionable.Controller.Steps.Interactions;
using Questionable.Data;
using Questionable.Model;
using Questionable.Model.Questing;
using GrandCompany = FFXIVClientStructs.FFXIV.Client.UI.Agent.GrandCompany;
using Quest = Questionable.Model.Quest;
namespace Questionable.Functions;
internal sealed unsafe class QuestFunctions
{
private readonly QuestRegistry _questRegistry;
private readonly QuestData _questData;
private readonly AetheryteFunctions _aetheryteFunctions;
private readonly Configuration _configuration;
private readonly IDataManager _dataManager;
private readonly IClientState _clientState;
private readonly IGameGui _gameGui;
public QuestFunctions(
QuestRegistry questRegistry,
QuestData questData,
AetheryteFunctions aetheryteFunctions,
Configuration configuration,
IDataManager dataManager,
IClientState clientState,
IGameGui gameGui)
{
_questRegistry = questRegistry;
_questData = questData;
_aetheryteFunctions = aetheryteFunctions;
_configuration = configuration;
_dataManager = dataManager;
_clientState = clientState;
_gameGui = gameGui;
}
public (ElementId? CurrentQuest, byte Sequence) GetCurrentQuest()
{
var (currentQuest, sequence) = GetCurrentQuestInternal();
PlayerState* playerState = PlayerState.Instance();
if (currentQuest == null || currentQuest.Value == 0)
{
if (_clientState.TerritoryType == 181) // Starting in Limsa
return (new QuestId(107), 0);
if (_clientState.TerritoryType == 182) // Starting in Ul'dah
return (new QuestId(594), 0);
if (_clientState.TerritoryType == 183) // Starting in Gridania
return (new QuestId(39), 0);
return default;
}
else if (currentQuest.Value == 681)
{
// if we have already picked up the GC quest, just return the progress for it
if (IsQuestAccepted(currentQuest) || IsQuestComplete(currentQuest))
return (currentQuest, sequence);
// The company you keep...
return _configuration.General.GrandCompany switch
{
GrandCompany.TwinAdder => (new QuestId(680), 0),
GrandCompany.Maelstrom => (new QuestId(681), 0),
GrandCompany.ImmortalFlames => (new QuestId(682), 0),
_ => default
};
}
else if (currentQuest.Value == 3856 && !playerState->IsMountUnlocked(1)) // we come in peace
{
ushort chocoboQuest = (GrandCompany)playerState->GrandCompany switch
{
GrandCompany.TwinAdder => 700,
GrandCompany.Maelstrom => 701,
GrandCompany.ImmortalFlames => 702,
_ => 0
};
if (chocoboQuest != 0 && !QuestManager.IsQuestComplete(chocoboQuest))
return (new QuestId(chocoboQuest), QuestManager.GetQuestSequence(chocoboQuest));
}
else if (currentQuest.Value == 801)
{
// skeletons in her closet, finish 'broadening horizons' to unlock the white wolf gate
QuestId broadeningHorizons = new QuestId(802);
if (IsQuestAccepted(broadeningHorizons))
return (broadeningHorizons, QuestManager.GetQuestSequence(broadeningHorizons.Value));
}
return (currentQuest, sequence);
}
public (ElementId? CurrentQuest, byte Sequence) GetCurrentQuestInternal()
{
var questManager = QuestManager.Instance();
if (questManager != null)
{
// always prioritize accepting MSQ quests, to make sure we don't turn in one MSQ quest and then go off to do
// side quests until the end of time.
var msqQuest = GetMainScenarioQuest();
if (msqQuest.CurrentQuest != null && !_questRegistry.IsKnownQuest(msqQuest.CurrentQuest))
msqQuest = default;
if (msqQuest.CurrentQuest != null && !IsQuestAccepted(msqQuest.CurrentQuest))
return msqQuest;
// Use the quests in the same order as they're shown in the to-do list, e.g. if the MSQ is the first item,
// do the MSQ; if a side quest is the first item do that side quest.
//
// If no quests are marked as 'priority', accepting a new quest adds it to the top of the list.
for (int i = questManager->TrackedQuests.Length - 1; i >= 0; --i)
{
ElementId currentQuest;
var trackedQuest = questManager->TrackedQuests[i];
switch (trackedQuest.QuestType)
{
default:
continue;
case 1: // normal quest
currentQuest = new QuestId(questManager->NormalQuests[trackedQuest.Index].QuestId);
if (_questRegistry.IsKnownQuest(currentQuest))
return (currentQuest, QuestManager.GetQuestSequence(currentQuest.Value));
continue;
case 2: // leve
currentQuest = new LeveId(questManager->LeveQuests[trackedQuest.Index].LeveId);
if (_questRegistry.IsKnownQuest(currentQuest))
return (currentQuest, questManager->GetLeveQuestById(currentQuest.Value)->Sequence);
continue;
}
}
ElementId? priorityQuest = GetNextPriorityQuestThatCanBeAccepted();
if (priorityQuest != null)
{
// if we have an accepted msq quest, and know of no quest of those currently in the to-do list...
// (1) try and find a priority quest to do
return (priorityQuest, QuestManager.GetQuestSequence(priorityQuest.Value));
}
else if (msqQuest.CurrentQuest != null)
{
// (2) just do a normal msq quest
return msqQuest;
}
}
return default;
}
private (QuestId? CurrentQuest, byte Sequence) GetMainScenarioQuest()
{
if (QuestManager.IsQuestComplete(3759)) // Memories Rekindled
{
AgentInterface* questRedoHud = AgentModule.Instance()->GetAgentByInternalId(AgentId.QuestRedoHud);
if (questRedoHud != null && questRedoHud->IsAgentActive())
{
// there's surely better ways to check this, but the one in the OOB Plugin was even less reliable
if (_gameGui.TryGetAddonByName<AtkUnitBase>("QuestRedoHud", out var addon) &&
addon->AtkValuesCount == 4 &&
// 0 seems to be active,
// 1 seems to be paused,
// 2 is unknown, but it happens e.g. before the quest 'Alzadaal's Legacy'
// 3 seems to be having /ng+ open while active,
// 4 seems to be when (a) suspending the chapter, or (b) having turned in a quest
addon->AtkValues[0].UInt is 0 or 2 or 3 or 4)
{
// redoHud+44 is chapter
// redoHud+46 is quest
ushort questId = MemoryHelper.Read<ushort>((nint)questRedoHud + 46);
return (new QuestId(questId), QuestManager.GetQuestSequence(questId));
}
}
}
var scenarioTree = AgentScenarioTree.Instance();
if (scenarioTree == null)
return default;
if (scenarioTree->Data == null)
return default;
QuestId currentQuest = new QuestId(scenarioTree->Data->CurrentScenarioQuest);
if (currentQuest.Value == 0)
return default;
// if the MSQ is hidden, we generally ignore it
QuestManager* questManager = QuestManager.Instance();
if (IsQuestAccepted(currentQuest) && questManager->GetQuestById(currentQuest.Value)->IsHidden)
return default;
// it can sometimes happen (although this isn't reliably reproducible) that the quest returned here
// is one you've just completed. We return 255 as sequence here, since that is the end of said quest;
// but this is just really hoping that this breaks nothing.
if (IsQuestComplete(currentQuest))
return (currentQuest, 255);
else if (!IsReadyToAcceptQuest(currentQuest))
return default;
// if we're not at a high enough level to continue, we also ignore it
var currentLevel = _clientState.LocalPlayer?.Level ?? 0;
if (currentLevel != 0 &&
_questRegistry.TryGetQuest(currentQuest, out Quest? quest)
&& quest.Info.Level > currentLevel)
return default;
return (currentQuest, QuestManager.GetQuestSequence(currentQuest.Value));
}
public QuestProgressInfo? GetQuestProgressInfo(ElementId elementId)
{
if (elementId is QuestId questId)
{
QuestWork* questWork = QuestManager.Instance()->GetQuestById(questId.Value);
return questWork != null ? new QuestProgressInfo(*questWork) : null;
}
else if (elementId is LeveId leveId)
{
LeveWork* leveWork = QuestManager.Instance()->GetLeveQuestById(leveId.Value);
return leveWork != null ? new QuestProgressInfo(*leveWork) : null;
}
else
return null;
}
public ElementId? GetNextPriorityQuestThatCanBeAccepted()
{
// all priority quests assume we're able to teleport to the beginning (and for e.g. class quests, the end)
// ideally without having to wait 15m for Return.
if (!_aetheryteFunctions.IsTeleportUnlocked())
return null;
// ideally, we'd also be able to afford *some* teleports
// this implicitly makes sure we're not starting one of the lv1 class quests if we can't afford to teleport back
//
// Of course, they can still be accepted manually.
InventoryManager* inventoryManager = InventoryManager.Instance();
int gil = inventoryManager->GetItemCountInContainer(1, InventoryType.Currency);
return GetPriorityQuests()
.Where(IsReadyToAcceptQuest)
.Where(x =>
{
if (!_questRegistry.TryGetQuest(x, out Quest? quest))
return false;
var firstStep = quest.FindSequence(0)?.FindStep(0);
if (firstStep == null)
return false;
return firstStep.IsTeleportableForPriorityQuests();
})
.FirstOrDefault(x =>
{
if (!_questRegistry.TryGetQuest(x, out Quest? quest))
return false;
if (gil < EstimateTeleportCosts(quest))
return false;
return quest.AllSteps().All(y =>
{
if (y.Step.AetheryteShortcut is { } aetheryteShortcut &&
!_aetheryteFunctions.IsAetheryteUnlocked(aetheryteShortcut))
{
if (y.Step.SkipConditions?.AetheryteShortcutIf?.AetheryteLocked == aetheryteShortcut)
{
// _logger.LogTrace("Checking priority quest {QuestId}: aetheryte locked, but is listed as skippable", quest.Id);
}
else return false;
}
if (y.Step.AethernetShortcut is { } aethernetShortcut &&
(!_aetheryteFunctions.IsAetheryteUnlocked(aethernetShortcut.From) ||
!_aetheryteFunctions.IsAetheryteUnlocked(aethernetShortcut.To)))
return false;
return true;
});
});
}
private static int EstimateTeleportCosts(Quest quest)
{
if (quest.Info.Expansion == EExpansionVersion.ARealmReborn)
return 300 * quest.AllSteps().Count(x => x.Step.AetheryteShortcut != null);
else
return 1000 * quest.AllSteps().Count(x => x.Step.AetheryteShortcut != null);
}
public List<ElementId> GetPriorityQuests()
{
List<ElementId> priorityQuests =
[
new QuestId(1157), // Garuda (Hard)
new QuestId(1158), // Titan (Hard)
..QuestData.CrystalTowerQuests
];
EClassJob classJob = (EClassJob?)_clientState.LocalPlayer?.ClassJob.Id ?? EClassJob.Adventurer;
ushort[] shadowbringersRoleQuestChapters = QuestData.AllRoleQuestChapters.Select(x => x[0]).ToArray();
if (classJob != EClassJob.Adventurer)
{
priorityQuests.AddRange(_questRegistry.GetKnownClassJobQuests(classJob)
.Where(x =>
{
if (!_questRegistry.TryGetQuest(x.QuestId, out Quest? quest) ||
quest.Info is not QuestInfo questInfo)
return false;
// if no shadowbringers role quest is complete, (at least one) is required
if (shadowbringersRoleQuestChapters.Contains(questInfo.NewGamePlusChapter))
return !QuestData.FinalShadowbringersRoleQuests.Any(IsQuestComplete);
// ignore all other role quests
if (QuestData.AllRoleQuestChapters.Any(y => y.Contains(questInfo.NewGamePlusChapter)))
return false;
// even job quests for the later expacs (after role quests were introduced) might have skills locked
// behind them, e.g. reaper and sage
return true;
})
.Select(x => x.QuestId));
}
return priorityQuests
.Where(_questRegistry.IsKnownQuest)
.ToList();
}
public bool IsReadyToAcceptQuest(ElementId questId)
{
_questRegistry.TryGetQuest(questId, out var quest);
if (quest is { Info.IsRepeatable: true })
{
if (IsQuestAccepted(questId))
return false;
if (QuestManager.Instance()->IsDailyQuestCompleted(questId.Value))
return false;
}
else
{
if (IsQuestAcceptedOrComplete(questId))
return false;
}
if (IsQuestLocked(questId))
return false;
// if we're not at a high enough level to continue, we also ignore it
var currentLevel = _clientState.LocalPlayer?.Level ?? 0;
if (currentLevel != 0 && quest != null && quest.Info.Level > currentLevel)
return false;
return true;
}
public bool IsQuestAcceptedOrComplete(ElementId elementId)
{
return IsQuestComplete(elementId) || IsQuestAccepted(elementId);
}
public bool IsQuestAccepted(ElementId elementId)
{
if (elementId is QuestId questId)
return IsQuestAccepted(questId);
else if (elementId is LeveId leveId)
return IsQuestAccepted(leveId);
else if (elementId is SatisfactionSupplyNpcId)
return false;
else
throw new ArgumentOutOfRangeException(nameof(elementId));
}
public bool IsQuestAccepted(QuestId questId)
{
QuestManager* questManager = QuestManager.Instance();
return questManager->IsQuestAccepted(questId.Value);
}
public bool IsQuestAccepted(LeveId leveId)
{
QuestManager* questManager = QuestManager.Instance();
foreach (var leveQuest in questManager->LeveQuests)
{
if (leveQuest.LeveId == leveId.Value)
return true;
}
return false;
}
public bool IsQuestComplete(ElementId elementId)
{
if (elementId is QuestId questId)
return IsQuestComplete(questId);
else if (elementId is LeveId leveId)
return IsQuestComplete(leveId);
else if (elementId is SatisfactionSupplyNpcId)
return false;
else
throw new ArgumentOutOfRangeException(nameof(elementId));
}
[SuppressMessage("Performance", "CA1822")]
public bool IsQuestComplete(QuestId questId)
{
return QuestManager.IsQuestComplete(questId.Value);
}
public bool IsQuestComplete(LeveId leveId)
{
return QuestManager.Instance()->IsLevequestComplete(leveId.Value);
}
public bool IsQuestLocked(ElementId elementId, ElementId? extraCompletedQuest = null)
{
if (elementId is QuestId questId)
return IsQuestLocked(questId, extraCompletedQuest);
else if (elementId is LeveId leveId)
return IsQuestLocked(leveId);
else if (elementId is SatisfactionSupplyNpcId satisfactionSupplyNpcId)
return IsQuestLocked(satisfactionSupplyNpcId);
else
throw new ArgumentOutOfRangeException(nameof(elementId));
}
private bool IsQuestLocked(QuestId questId, ElementId? extraCompletedQuest = null)
{
if (IsQuestUnobtainable(questId, extraCompletedQuest))
return true;
var questInfo = (QuestInfo)_questData.GetQuestInfo(questId);
if (questInfo.GrandCompany != GrandCompany.None && questInfo.GrandCompany != GetGrandCompany())
return true;
return !HasCompletedPreviousQuests(questInfo, extraCompletedQuest) || !HasCompletedPreviousInstances(questInfo);
}
private bool IsQuestLocked(LeveId leveId)
{
if (IsQuestUnobtainable(leveId))
return true;
// this only checks for the current class
IQuestInfo questInfo = _questData.GetQuestInfo(leveId);
if (!questInfo.ClassJobs.Contains((EClassJob)_clientState.LocalPlayer!.ClassJob.Id) ||
questInfo.Level > _clientState.LocalPlayer.Level)
return true;
return !IsQuestAccepted(leveId) && QuestManager.Instance()->NumLeveAllowances == 0;
}
private bool IsQuestLocked(SatisfactionSupplyNpcId satisfactionSupplyNpcId)
{
SatisfactionSupplyInfo questInfo = (SatisfactionSupplyInfo)_questData.GetQuestInfo(satisfactionSupplyNpcId);
return !HasCompletedPreviousQuests(questInfo, null);
}
public bool IsQuestUnobtainable(ElementId elementId, ElementId? extraCompletedQuest = null)
{
if (elementId is QuestId questId)
return IsQuestUnobtainable(questId, extraCompletedQuest);
else if (elementId is LeveId leveId)
return IsQuestUnobtainable(leveId);
else
return false;
}
public bool IsQuestUnobtainable(QuestId questId, ElementId? extraCompletedQuest = null)
{
var questInfo = (QuestInfo)_questData.GetQuestInfo(questId);
if (questInfo.Expansion > (EExpansionVersion)PlayerState.Instance()->MaxExpansion)
return true;
if (questInfo.QuestLocks.Count > 0)
{
var completedQuests = questInfo.QuestLocks.Count(x => IsQuestComplete(x) || x.Equals(extraCompletedQuest));
if (questInfo.QuestLockJoin == EQuestJoin.All && questInfo.QuestLocks.Count == completedQuests)
return true;
else if (questInfo.QuestLockJoin == EQuestJoin.AtLeastOne && completedQuests > 0)
return true;
}
if (_questData.GetLockedClassQuests().Contains(questId))
return true;
var startingCity = PlayerState.Instance()->StartTown;
if (questInfo.StartingCity > 0 && questInfo.StartingCity != startingCity)
return true;
if (questId.Value == 674 && startingCity == 3)
return true;
if (questId.Value == 673 && startingCity != 3)
return true;
Dictionary<ushort, EClassJob> closeToHomeQuests = new()
{
{ 108, EClassJob.Marauder },
{ 109, EClassJob.Arcanist },
{ 85, EClassJob.Lancer },
{ 123, EClassJob.Archer },
{ 124, EClassJob.Conjurer },
{ 568, EClassJob.Gladiator },
{ 569, EClassJob.Pugilist },
{ 570, EClassJob.Thaumaturge }
};
// The starting class experience is a bit confusing. If you start in Gridania, the MSQ next quest data will
// always select 'Close to Home (Lancer)' even if starting as Conjurer/Archer. However, if we always mark the
// Lancer quest as unobtainable, it'll not get picked up as Conjurer/Archer, and thus will stop questing.
//
// While the NPC offers all 3 quests, there's no manual selection, and interacting will automatically select the
// quest for your current class, then switch you from a dead-ish intro zone to the actual starting city
// (so that you can't come back later to pick up another quest).
if (closeToHomeQuests.TryGetValue(questId.Value, out EClassJob neededStartingClass) &&
closeToHomeQuests.Any(x => IsQuestAcceptedOrComplete(new QuestId(x.Key))))
{
EClassJob actualStartingClass = (EClassJob)PlayerState.Instance()->FirstClass;
if (actualStartingClass != neededStartingClass)
return true;
}
return false;
}
private bool IsQuestUnobtainable(LeveId leveId)
{
IQuestInfo questInfo = _questData.GetQuestInfo(leveId);
if (questInfo.Expansion > (EExpansionVersion)PlayerState.Instance()->MaxExpansion)
return true;
return false;
}
private bool HasCompletedPreviousQuests(IQuestInfo questInfo, ElementId? extraCompletedQuest)
{
if (questInfo.PreviousQuests.Count == 0)
return true;
var completedQuests = questInfo.PreviousQuests.Count(x =>
HasEnoughProgressOnPreviousQuest(x) || x.QuestId.Equals(extraCompletedQuest));
if (questInfo.PreviousQuestJoin == EQuestJoin.All &&
questInfo.PreviousQuests.Count == completedQuests)
return true;
else if (questInfo.PreviousQuestJoin == EQuestJoin.AtLeastOne && completedQuests > 0)
return true;
else
return false;
}
private bool HasEnoughProgressOnPreviousQuest(PreviousQuestInfo previousQuestInfo)
{
if (IsQuestComplete(previousQuestInfo.QuestId))
return true;
if (previousQuestInfo.Sequence != 0 && IsQuestAccepted(previousQuestInfo.QuestId))
{
var progress = GetQuestProgressInfo(previousQuestInfo.QuestId);
return progress != null && progress.Sequence >= previousQuestInfo.Sequence;
}
return false;
}
private static bool HasCompletedPreviousInstances(QuestInfo questInfo)
{
if (questInfo.PreviousInstanceContent.Count == 0)
return true;
var completedInstances = questInfo.PreviousInstanceContent.Count(x => UIState.IsInstanceContentCompleted(x));
if (questInfo.PreviousInstanceContentJoin == EQuestJoin.All &&
questInfo.PreviousInstanceContent.Count == completedInstances)
return true;
else if (questInfo.PreviousInstanceContentJoin == EQuestJoin.AtLeastOne && completedInstances > 0)
return true;
else
return false;
}
public bool IsClassJobUnlocked(EClassJob classJob)
{
var classJobRow = _dataManager.GetExcelSheet<ClassJob>()!.GetRow((uint)classJob)!;
var questId = (ushort)classJobRow.UnlockQuest.Row;
if (questId != 0)
return IsQuestComplete(new QuestId(questId));
PlayerState* playerState = PlayerState.Instance();
return playerState != null && playerState->ClassJobLevels[classJobRow.ExpArrayIndex] > 0;
}
public bool IsJobUnlocked(EClassJob classJob)
{
var classJobRow = _dataManager.GetExcelSheet<ClassJob>()!.GetRow((uint)classJob)!;
return IsClassJobUnlocked((EClassJob)classJobRow.ClassJobParent.Row);
}
public GrandCompany GetGrandCompany()
{
return (GrandCompany)PlayerState.Instance()->GrandCompany;
}
}