forked from liza/Questionable
601 lines
21 KiB
C#
601 lines
21 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Numerics;
|
|
using System.Text.Json;
|
|
using Dalamud.Game.ClientState.Conditions;
|
|
using Dalamud.Game.ClientState.Objects.Types;
|
|
using Dalamud.Plugin;
|
|
using Dalamud.Plugin.Services;
|
|
using FFXIVClientStructs.FFXIV.Client.Game;
|
|
using Questionable.Data;
|
|
using Questionable.External;
|
|
using Questionable.Model.V1;
|
|
using Questionable.Model.V1.Converter;
|
|
|
|
namespace Questionable.Controller;
|
|
|
|
internal sealed class QuestController
|
|
{
|
|
private readonly DalamudPluginInterface _pluginInterface;
|
|
private readonly IDataManager _dataManager;
|
|
private readonly IClientState _clientState;
|
|
private readonly GameFunctions _gameFunctions;
|
|
private readonly MovementController _movementController;
|
|
private readonly IPluginLog _pluginLog;
|
|
private readonly ICondition _condition;
|
|
private readonly IChatGui _chatGui;
|
|
private readonly AetheryteData _aetheryteData;
|
|
private readonly LifestreamIpc _lifestreamIpc;
|
|
private readonly TerritoryData _territoryData;
|
|
private readonly Dictionary<ushort, Quest> _quests = new();
|
|
|
|
public QuestController(DalamudPluginInterface pluginInterface, IDataManager dataManager, IClientState clientState,
|
|
GameFunctions gameFunctions, MovementController movementController, IPluginLog pluginLog, ICondition condition,
|
|
IChatGui chatGui, AetheryteData aetheryteData, LifestreamIpc lifestreamIpc)
|
|
{
|
|
_pluginInterface = pluginInterface;
|
|
_dataManager = dataManager;
|
|
_clientState = clientState;
|
|
_gameFunctions = gameFunctions;
|
|
_movementController = movementController;
|
|
_pluginLog = pluginLog;
|
|
_condition = condition;
|
|
_chatGui = chatGui;
|
|
_aetheryteData = aetheryteData;
|
|
_lifestreamIpc = lifestreamIpc;
|
|
_territoryData = new TerritoryData(dataManager);
|
|
|
|
Reload();
|
|
_gameFunctions.QuestController = this;
|
|
}
|
|
|
|
|
|
public QuestProgress? CurrentQuest { get; set; }
|
|
public string? DebugState { get; private set; }
|
|
public string? Comment { get; private set; }
|
|
|
|
public void Reload()
|
|
{
|
|
_quests.Clear();
|
|
|
|
CurrentQuest = null;
|
|
DebugState = null;
|
|
|
|
#if false
|
|
LoadFromEmbeddedResources();
|
|
#endif
|
|
LoadFromDirectory(new DirectoryInfo(@"E:\ffxiv\Questionable\Questionable\QuestPaths"));
|
|
LoadFromDirectory(_pluginInterface.ConfigDirectory);
|
|
|
|
foreach (var (questId, quest) in _quests)
|
|
{
|
|
var questData =
|
|
_dataManager.GetExcelSheet<Lumina.Excel.GeneratedSheets.Quest>()!.GetRow((uint)questId + 0x10000);
|
|
if (questData == null)
|
|
continue;
|
|
|
|
quest.Name = questData.Name.ToString();
|
|
}
|
|
}
|
|
|
|
#if false
|
|
private void LoadFromEmbeddedResources()
|
|
{
|
|
foreach (string resourceName in typeof(Questionable).Assembly.GetManifestResourceNames())
|
|
{
|
|
if (resourceName.EndsWith(".json"))
|
|
{
|
|
var (questId, name) = ExtractQuestDataFromName(resourceName);
|
|
Quest quest = new Quest
|
|
{
|
|
QuestId = questId,
|
|
Name = name,
|
|
Data = JsonSerializer.Deserialize<QuestData>(
|
|
typeof(Questionable).Assembly.GetManifestResourceStream(resourceName)!)!,
|
|
};
|
|
_quests[questId] = quest;
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
public bool IsKnownQuest(ushort questId) => _quests.ContainsKey(questId);
|
|
|
|
private void LoadFromDirectory(DirectoryInfo configDirectory)
|
|
{
|
|
foreach (FileInfo fileInfo in configDirectory.GetFiles("*.json"))
|
|
{
|
|
try
|
|
{
|
|
using FileStream stream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read);
|
|
var (questId, name) = ExtractQuestDataFromName(fileInfo.Name);
|
|
Quest quest = new Quest
|
|
{
|
|
FilePath = fileInfo.FullName,
|
|
QuestId = questId,
|
|
Name = name,
|
|
Data = JsonSerializer.Deserialize<QuestData>(stream)!,
|
|
};
|
|
_quests[questId] = quest;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
throw new InvalidDataException($"Unable to load file {fileInfo.FullName}", e);
|
|
}
|
|
}
|
|
|
|
foreach (DirectoryInfo childDirectory in configDirectory.GetDirectories())
|
|
LoadFromDirectory(childDirectory);
|
|
}
|
|
|
|
private static (ushort QuestId, string Name) ExtractQuestDataFromName(string resourceName)
|
|
{
|
|
string name = resourceName.Substring(0, resourceName.Length - ".json".Length);
|
|
name = name.Substring(name.LastIndexOf('.') + 1);
|
|
|
|
string[] parts = name.Split('_', 2);
|
|
return (ushort.Parse(parts[0], CultureInfo.InvariantCulture), parts[1]);
|
|
}
|
|
|
|
public void Update()
|
|
{
|
|
DebugState = null;
|
|
|
|
(ushort currentQuestId, byte currentSequence) = _gameFunctions.GetCurrentQuest();
|
|
if (currentQuestId == 0)
|
|
{
|
|
if (CurrentQuest != null)
|
|
CurrentQuest = null;
|
|
}
|
|
else if (CurrentQuest == null || CurrentQuest.Quest.QuestId != currentQuestId)
|
|
{
|
|
if (_quests.TryGetValue(currentQuestId, out var quest))
|
|
CurrentQuest = new QuestProgress(quest, currentSequence, 0);
|
|
else if (CurrentQuest != null)
|
|
CurrentQuest = null;
|
|
}
|
|
|
|
if (CurrentQuest == null)
|
|
{
|
|
DebugState = "No quest active";
|
|
Comment = null;
|
|
return;
|
|
}
|
|
|
|
if (_condition[ConditionFlag.Occupied] || _condition[ConditionFlag.Occupied30] ||
|
|
_condition[ConditionFlag.Occupied33] || _condition[ConditionFlag.Occupied38] ||
|
|
_condition[ConditionFlag.Occupied39] || _condition[ConditionFlag.OccupiedInEvent] ||
|
|
_condition[ConditionFlag.OccupiedInQuestEvent] || _condition[ConditionFlag.OccupiedInCutSceneEvent] ||
|
|
_condition[ConditionFlag.Casting] || _condition[ConditionFlag.Unknown57])
|
|
{
|
|
DebugState = "Occupied";
|
|
return;
|
|
}
|
|
|
|
if (!_movementController.IsNavmeshReady)
|
|
{
|
|
DebugState = "Navmesh not ready";
|
|
return;
|
|
}
|
|
else if (_movementController.IsPathfinding || _movementController.IsPathRunning)
|
|
{
|
|
DebugState = "Path is running";
|
|
return;
|
|
}
|
|
|
|
if (CurrentQuest.Sequence != currentSequence)
|
|
CurrentQuest = CurrentQuest with { Sequence = currentSequence, Step = 0 };
|
|
|
|
var q = CurrentQuest.Quest;
|
|
var sequence = q.FindSequence(CurrentQuest.Sequence);
|
|
if (sequence == null)
|
|
{
|
|
DebugState = "Sequence not found";
|
|
Comment = null;
|
|
return;
|
|
}
|
|
|
|
if (CurrentQuest.Step == 255)
|
|
{
|
|
DebugState = "Step completed";
|
|
Comment = null;
|
|
return;
|
|
}
|
|
|
|
if (CurrentQuest.Step >= sequence.Steps.Count)
|
|
{
|
|
DebugState = "Step not found";
|
|
Comment = null;
|
|
return;
|
|
}
|
|
|
|
var step = sequence.Steps[CurrentQuest.Step];
|
|
DebugState = null;
|
|
Comment = step.Comment ?? sequence.Comment ?? q.Data.Comment;
|
|
}
|
|
|
|
public (QuestSequence? Sequence, QuestStep? Step) GetNextStep()
|
|
{
|
|
if (CurrentQuest == null)
|
|
return (null, null);
|
|
|
|
var q = CurrentQuest.Quest;
|
|
var seq = q.FindSequence(CurrentQuest.Sequence);
|
|
if (seq == null)
|
|
return (null, null);
|
|
|
|
if (CurrentQuest.Step >= seq.Steps.Count)
|
|
return (null, null);
|
|
|
|
return (seq, seq.Steps[CurrentQuest.Step]);
|
|
}
|
|
|
|
public void IncreaseStepCount()
|
|
{
|
|
(QuestSequence? seq, QuestStep? step) = GetNextStep();
|
|
if (seq == null || step == null)
|
|
return;
|
|
|
|
Debug.Assert(CurrentQuest != null, nameof(CurrentQuest) + " != null");
|
|
if (CurrentQuest.Step + 1 < seq.Steps.Count)
|
|
{
|
|
CurrentQuest = CurrentQuest with
|
|
{
|
|
Step = CurrentQuest.Step + 1,
|
|
StepProgress = new()
|
|
};
|
|
}
|
|
else
|
|
{
|
|
CurrentQuest = CurrentQuest with
|
|
{
|
|
Step = 255,
|
|
StepProgress = new()
|
|
};
|
|
}
|
|
}
|
|
|
|
public unsafe void ExecuteNextStep()
|
|
{
|
|
(QuestSequence? seq, QuestStep? step) = GetNextStep();
|
|
if (seq == null || step == null)
|
|
return;
|
|
|
|
Debug.Assert(CurrentQuest != null, nameof(CurrentQuest) + " != null");
|
|
if (step.Disabled)
|
|
{
|
|
_pluginLog.Information("Skipping step, as it is disabled");
|
|
IncreaseStepCount();
|
|
return;
|
|
}
|
|
|
|
if (!CurrentQuest.StepProgress.AetheryteShortcutUsed && step.AetheryteShortcut != null)
|
|
{
|
|
bool skipTeleport = false;
|
|
ushort territoryType = _clientState.TerritoryType;
|
|
if (step.TerritoryId == territoryType)
|
|
{
|
|
Vector3 pos = _clientState.LocalPlayer!.Position;
|
|
if (_aetheryteData.CalculateDistance(pos, territoryType, step.AetheryteShortcut.Value) < 11 ||
|
|
(step.AethernetShortcut != null &&
|
|
(_aetheryteData.CalculateDistance(pos, territoryType, step.AethernetShortcut.From) < 20 ||
|
|
_aetheryteData.CalculateDistance(pos, territoryType, step.AethernetShortcut.To) < 20)))
|
|
{
|
|
skipTeleport = true;
|
|
}
|
|
}
|
|
|
|
if (skipTeleport)
|
|
{
|
|
CurrentQuest = CurrentQuest with
|
|
{
|
|
StepProgress = CurrentQuest.StepProgress with { AetheryteShortcutUsed = true }
|
|
};
|
|
}
|
|
else
|
|
{
|
|
if (step.AetheryteShortcut != null)
|
|
{
|
|
if (!_gameFunctions.IsAetheryteUnlocked(step.AetheryteShortcut.Value))
|
|
_chatGui.Print($"[Questionable] Aetheryte {step.AetheryteShortcut.Value} is not unlocked.");
|
|
else if (_gameFunctions.TeleportAetheryte(step.AetheryteShortcut.Value))
|
|
CurrentQuest = CurrentQuest with
|
|
{
|
|
StepProgress = CurrentQuest.StepProgress with { AetheryteShortcutUsed = true }
|
|
};
|
|
else
|
|
_chatGui.Print("[Questionable] Unable to teleport to aetheryte.");
|
|
}
|
|
else
|
|
_chatGui.Print("[Questionable] No aetheryte for teleport set.");
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (step.SkipIf.Contains(ESkipCondition.FlyingUnlocked) && _gameFunctions.IsFlyingUnlocked(step.TerritoryId))
|
|
{
|
|
_pluginLog.Information("Skipping step, as flying is unlocked");
|
|
IncreaseStepCount();
|
|
return;
|
|
}
|
|
|
|
if (!CurrentQuest.StepProgress.AethernetShortcutUsed)
|
|
{
|
|
if (step.AethernetShortcut != null &&
|
|
_gameFunctions.IsAetheryteUnlocked(step.AethernetShortcut.From) &&
|
|
_gameFunctions.IsAetheryteUnlocked(step.AethernetShortcut.To))
|
|
{
|
|
EAetheryteLocation from = step.AethernetShortcut.From;
|
|
EAetheryteLocation to = step.AethernetShortcut.To;
|
|
ushort territoryType = _clientState.TerritoryType;
|
|
Vector3 playerPosition = _clientState.LocalPlayer!.Position;
|
|
|
|
// closer to the source
|
|
if (_aetheryteData.CalculateDistance(playerPosition, territoryType, from) <
|
|
_aetheryteData.CalculateDistance(playerPosition, territoryType, to))
|
|
{
|
|
if (_aetheryteData.CalculateDistance(playerPosition, territoryType, from) < 11)
|
|
{
|
|
_lifestreamIpc.Teleport(to);
|
|
CurrentQuest = CurrentQuest with
|
|
{
|
|
StepProgress = CurrentQuest.StepProgress with { AethernetShortcutUsed = true }
|
|
};
|
|
}
|
|
else
|
|
_movementController.NavigateTo(EMovementType.Quest, (uint)from, _aetheryteData.Locations[from], false,
|
|
AetheryteConverter.IsLargeAetheryte(from) ? 10.9f : 6.9f);
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (step.TargetTerritoryId == _clientState.TerritoryType)
|
|
{
|
|
_pluginLog.Information("Skipping any movement");
|
|
}
|
|
else if (step.Position != null)
|
|
{
|
|
float distance;
|
|
if (step.InteractionType == EInteractionType.WalkTo)
|
|
distance = step.StopDistance ?? 0.25f;
|
|
else
|
|
distance = step.StopDistance ?? MovementController.DefaultStopDistance;
|
|
|
|
var position = _clientState.LocalPlayer?.Position ?? new Vector3();
|
|
float actualDistance = (position - step.Position.Value).Length();
|
|
|
|
if (step.Mount == true && !_gameFunctions.HasStatusPreventingSprintOrMount())
|
|
{
|
|
if (!_condition[ConditionFlag.Mounted] && !_condition[ConditionFlag.InCombat] &&
|
|
_territoryData.CanUseMount(_clientState.TerritoryType))
|
|
{
|
|
if (ActionManager.Instance()->GetActionStatus(ActionType.Mount, 71) == 0)
|
|
ActionManager.Instance()->UseAction(ActionType.Mount, 71);
|
|
return;
|
|
}
|
|
}
|
|
else if (step.Mount == false)
|
|
{
|
|
if (_condition[ConditionFlag.Mounted])
|
|
{
|
|
_gameFunctions.Unmount();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!step.DisableNavmesh)
|
|
{
|
|
if (step.Mount != false && actualDistance > 30f && !_condition[ConditionFlag.Mounted] &&
|
|
!_condition[ConditionFlag.InCombat] && _territoryData.CanUseMount(_clientState.TerritoryType) &&
|
|
!_gameFunctions.HasStatusPreventingSprintOrMount())
|
|
{
|
|
if (ActionManager.Instance()->GetActionStatus(ActionType.Mount, 71) == 0)
|
|
ActionManager.Instance()->UseAction(ActionType.Mount, 71);
|
|
|
|
return;
|
|
}
|
|
|
|
if (actualDistance > distance)
|
|
{
|
|
_movementController.NavigateTo(EMovementType.Quest, step.DataId, step.Position.Value,
|
|
step.Fly && _gameFunctions.IsFlyingUnlocked(_clientState.TerritoryType), distance);
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// navmesh won't move close enough
|
|
if (actualDistance > distance)
|
|
{
|
|
// picking up Mehvan's baby, not sure if navmesh ignores y distance but it thinks you're close
|
|
// enough
|
|
if (step.DataId == 2012208)
|
|
distance /= 2;
|
|
|
|
_movementController.NavigateTo(EMovementType.Quest, step.DataId, [step.Position.Value],
|
|
step.Fly && _gameFunctions.IsFlyingUnlocked(_clientState.TerritoryType), distance);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
else if (step.DataId != null && step.StopDistance != null)
|
|
{
|
|
GameObject? gameObject = _gameFunctions.FindObjectByDataId(step.DataId.Value);
|
|
if (gameObject == null ||
|
|
(gameObject.Position - _clientState.LocalPlayer!.Position).Length() > step.StopDistance)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
switch (step.InteractionType)
|
|
{
|
|
case EInteractionType.Interact:
|
|
if (step.DataId != null)
|
|
{
|
|
GameObject? gameObject = _gameFunctions.FindObjectByDataId(step.DataId.Value);
|
|
if (gameObject == null)
|
|
return;
|
|
|
|
if (!gameObject.IsTargetable && _condition[ConditionFlag.Mounted])
|
|
{
|
|
_gameFunctions.Unmount();
|
|
return;
|
|
}
|
|
|
|
_gameFunctions.InteractWith(step.DataId.Value);
|
|
IncreaseStepCount();
|
|
}
|
|
else
|
|
_pluginLog.Warning("Not interacting on current step, DataId is null");
|
|
|
|
break;
|
|
|
|
case EInteractionType.AttuneAethernetShard:
|
|
if (step.DataId != null)
|
|
{
|
|
if (!_gameFunctions.IsAetheryteUnlocked((EAetheryteLocation)step.DataId.Value))
|
|
_gameFunctions.InteractWith(step.DataId.Value);
|
|
|
|
IncreaseStepCount();
|
|
}
|
|
|
|
break;
|
|
|
|
case EInteractionType.AttuneAetheryte:
|
|
if (step.DataId != null)
|
|
{
|
|
if (!_gameFunctions.IsAetheryteUnlocked((EAetheryteLocation)step.DataId.Value))
|
|
_gameFunctions.InteractWith(step.DataId.Value);
|
|
|
|
IncreaseStepCount();
|
|
}
|
|
|
|
break;
|
|
|
|
case EInteractionType.AttuneAetherCurrent:
|
|
if (step.DataId != null)
|
|
{
|
|
_pluginLog.Information(
|
|
$"{step.AetherCurrentId} → {_gameFunctions.IsAetherCurrentUnlocked(step.AetherCurrentId.GetValueOrDefault())}");
|
|
if (step.AetherCurrentId == null ||
|
|
!_gameFunctions.IsAetherCurrentUnlocked(step.AetherCurrentId.Value))
|
|
_gameFunctions.InteractWith(step.DataId.Value);
|
|
|
|
IncreaseStepCount();
|
|
}
|
|
|
|
break;
|
|
|
|
case EInteractionType.WalkTo:
|
|
IncreaseStepCount();
|
|
break;
|
|
|
|
case EInteractionType.UseItem:
|
|
if (_gameFunctions.Unmount())
|
|
return;
|
|
|
|
if (step is { DataId: not null, ItemId: not null, GroundTarget: true })
|
|
{
|
|
_gameFunctions.UseItemOnGround(step.DataId.Value, step.ItemId.Value);
|
|
IncreaseStepCount();
|
|
}
|
|
else if (step is { DataId: not null, ItemId: not null })
|
|
{
|
|
_gameFunctions.UseItem(step.DataId.Value, step.ItemId.Value);
|
|
IncreaseStepCount();
|
|
}
|
|
else if (step.ItemId != null)
|
|
{
|
|
_gameFunctions.UseItem(step.ItemId.Value);
|
|
IncreaseStepCount();
|
|
}
|
|
|
|
break;
|
|
|
|
case EInteractionType.Combat:
|
|
if (_gameFunctions.Unmount())
|
|
return;
|
|
|
|
if (step.EnemySpawnType != null)
|
|
{
|
|
if (step is { DataId: not null, EnemySpawnType: EEnemySpawnType.AfterInteraction })
|
|
_gameFunctions.InteractWith(step.DataId.Value);
|
|
|
|
if (step is { DataId: not null, ItemId: not null, EnemySpawnType: EEnemySpawnType.AfterItemUse })
|
|
_gameFunctions.UseItem(step.DataId.Value, step.ItemId.Value);
|
|
|
|
// next sequence should trigger automatically
|
|
IncreaseStepCount();
|
|
}
|
|
|
|
break;
|
|
|
|
case EInteractionType.Emote:
|
|
if (step is { DataId: not null, Emote: not null })
|
|
{
|
|
_gameFunctions.UseEmote(step.DataId.Value, step.Emote.Value);
|
|
IncreaseStepCount();
|
|
}
|
|
else if (step.Emote != null)
|
|
{
|
|
_gameFunctions.UseEmote(step.Emote.Value);
|
|
IncreaseStepCount();
|
|
}
|
|
|
|
break;
|
|
|
|
case EInteractionType.Say:
|
|
if (_condition[ConditionFlag.Mounted])
|
|
{
|
|
_gameFunctions.Unmount();
|
|
return;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(step.ChatMessage))
|
|
{
|
|
_gameFunctions.ExecuteCommand($"/say {step.ChatMessage}");
|
|
IncreaseStepCount();
|
|
}
|
|
|
|
break;
|
|
|
|
case EInteractionType.WaitForObjectAtPosition:
|
|
if (step is { DataId: not null, Position: not null } &&
|
|
!_gameFunctions.IsObjectAtPosition(step.DataId.Value, step.Position.Value))
|
|
{
|
|
return;
|
|
}
|
|
|
|
IncreaseStepCount();
|
|
break;
|
|
|
|
default:
|
|
_pluginLog.Warning($"Action '{step.InteractionType}' is not implemented");
|
|
break;
|
|
}
|
|
}
|
|
|
|
public sealed record QuestProgress(
|
|
Quest Quest,
|
|
byte Sequence,
|
|
int Step,
|
|
StepProgress StepProgress)
|
|
{
|
|
public QuestProgress(Quest quest, byte sequence, int step)
|
|
: this(quest, sequence, step, new StepProgress())
|
|
{
|
|
}
|
|
}
|
|
|
|
public sealed record StepProgress(
|
|
bool AetheryteShortcutUsed = false,
|
|
bool AethernetShortcutUsed = false);
|
|
}
|