516 lines
17 KiB
C#
516 lines
17 KiB
C#
#define RELEASE
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.RegularExpressions;
|
|
using Dalamud.Plugin;
|
|
using Dalamud.Plugin.Ipc;
|
|
using Dalamud.Plugin.Services;
|
|
using LLib.GameData;
|
|
using Microsoft.Extensions.Logging;
|
|
using Questionable.Data;
|
|
using Questionable.Model;
|
|
using Questionable.Model.Questing;
|
|
using Questionable.QuestPaths;
|
|
using Questionable.Validation;
|
|
using Questionable.Validation.Validators;
|
|
|
|
namespace Questionable.Controller;
|
|
|
|
internal sealed class QuestRegistry : IDisposable
|
|
{
|
|
internal sealed class Snapshot
|
|
{
|
|
public Dictionary<ElementId, Quest> Quests { get; }
|
|
|
|
public Dictionary<ElementId, FailedQuestLoad> FailedLoads { get; }
|
|
|
|
public Dictionary<uint, (ElementId QuestId, QuestStep Step)> ContentFinderConditionIds { get; }
|
|
|
|
public List<(uint ContentFinderConditionId, ElementId QuestId, int Sequence)> LowPriorityContentFinderConditionQuests { get; }
|
|
|
|
public Dictionary<ElementId, string> QuestFolderNames { get; }
|
|
|
|
public int Count { get; set; }
|
|
|
|
public Snapshot()
|
|
{
|
|
Quests = new Dictionary<ElementId, Quest>();
|
|
FailedLoads = new Dictionary<ElementId, FailedQuestLoad>();
|
|
ContentFinderConditionIds = new Dictionary<uint, (ElementId, QuestStep)>();
|
|
LowPriorityContentFinderConditionQuests = new List<(uint, ElementId, int)>();
|
|
QuestFolderNames = new Dictionary<ElementId, string>();
|
|
}
|
|
|
|
public Snapshot(Snapshot other)
|
|
{
|
|
Quests = new Dictionary<ElementId, Quest>(other.Quests);
|
|
FailedLoads = new Dictionary<ElementId, FailedQuestLoad>(other.FailedLoads);
|
|
ContentFinderConditionIds = new Dictionary<uint, (ElementId, QuestStep)>(other.ContentFinderConditionIds);
|
|
LowPriorityContentFinderConditionQuests = new List<(uint, ElementId, int)>(other.LowPriorityContentFinderConditionQuests);
|
|
QuestFolderNames = new Dictionary<ElementId, string>(other.QuestFolderNames);
|
|
Count = other.Count;
|
|
}
|
|
}
|
|
|
|
internal sealed record FailedQuestLoad(ElementId QuestId, string? FilePath, string ErrorMessage, Quest.ESource Source);
|
|
|
|
private readonly IDalamudPluginInterface _pluginInterface;
|
|
|
|
private readonly QuestData _questData;
|
|
|
|
private readonly QuestValidator _questValidator;
|
|
|
|
private readonly JsonSchemaValidator _jsonSchemaValidator;
|
|
|
|
private readonly ILogger<QuestRegistry> _logger;
|
|
|
|
private readonly TerritoryData _territoryData;
|
|
|
|
private readonly IChatGui _chatGui;
|
|
|
|
private readonly ICallGateProvider<object> _reloadDataIpc;
|
|
|
|
private volatile Snapshot _snapshot = new Snapshot();
|
|
|
|
public IEnumerable<Quest> AllQuests => _snapshot.Quests.Values;
|
|
|
|
public int Count => _snapshot.Count;
|
|
|
|
public IReadOnlyDictionary<ElementId, FailedQuestLoad> FailedLoads => _snapshot.FailedLoads;
|
|
|
|
public IReadOnlyList<(uint ContentFinderConditionId, ElementId QuestId, int Sequence)> LowPriorityContentFinderConditionQuests => _snapshot.LowPriorityContentFinderConditionQuests;
|
|
|
|
public event EventHandler? Reloaded;
|
|
|
|
public QuestRegistry(IDalamudPluginInterface pluginInterface, QuestData questData, QuestValidator questValidator, JsonSchemaValidator jsonSchemaValidator, ILogger<QuestRegistry> logger, TerritoryData territoryData, IChatGui chatGui, IFramework framework)
|
|
{
|
|
_pluginInterface = pluginInterface;
|
|
_questData = questData;
|
|
_questValidator = questValidator;
|
|
_jsonSchemaValidator = jsonSchemaValidator;
|
|
_logger = logger;
|
|
_territoryData = territoryData;
|
|
_chatGui = chatGui;
|
|
_reloadDataIpc = _pluginInterface.GetIpcProvider<object>("Questionable.ReloadData");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
internal Snapshot Build()
|
|
{
|
|
_questValidator.Reset();
|
|
Snapshot snapshot = new Snapshot();
|
|
LoadQuestsFromAssembly(snapshot);
|
|
try
|
|
{
|
|
LoadFromDirectory(snapshot, new DirectoryInfo(Path.Combine(_pluginInterface.ConfigDirectory.FullName, "Quests")), Quest.ESource.UserDirectory);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogError(exception, "Failed to load all quests from user directory (some may have been successfully loaded)");
|
|
}
|
|
LoadCfcIds(snapshot);
|
|
snapshot.Count = snapshot.Quests.Count<KeyValuePair<ElementId, Quest>>((KeyValuePair<ElementId, Quest> x) => !x.Value.Root.Disabled);
|
|
return snapshot;
|
|
}
|
|
|
|
internal void Publish(Snapshot snapshot, bool sendReloadDataIpc = true, bool validateQuests = true)
|
|
{
|
|
_snapshot = snapshot;
|
|
if (validateQuests)
|
|
{
|
|
ValidateQuests();
|
|
}
|
|
this.Reloaded?.Invoke(this, EventArgs.Empty);
|
|
if (sendReloadDataIpc)
|
|
{
|
|
try
|
|
{
|
|
_reloadDataIpc.SendMessage();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogWarning(exception, "Error during Reload.SendMessage IPC");
|
|
}
|
|
}
|
|
_logger.LogDebug("Loaded {Count} quests in total", snapshot.Quests.Count);
|
|
}
|
|
|
|
public void Reload()
|
|
{
|
|
Publish(Build());
|
|
}
|
|
|
|
[Conditional("RELEASE")]
|
|
private void LoadQuestsFromAssembly(Snapshot snapshot)
|
|
{
|
|
foreach (var (elementId2, questRoot2) in AssemblyQuestLoader.GetQuests())
|
|
{
|
|
try
|
|
{
|
|
DateTime? dateTime = (questRoot2.SeasonalQuestExpiry.HasValue ? new DateTime?(DateTime.SpecifyKind(questRoot2.SeasonalQuestExpiry.Value, DateTimeKind.Utc)) : ((DateTime?)null));
|
|
if (_questData.TryGetQuestInfo(elementId2, out IQuestInfo questInfo))
|
|
{
|
|
goto IL_0173;
|
|
}
|
|
if (elementId2 is UnlockLinkId unlockLinkId)
|
|
{
|
|
string text = unlockLinkId.ToString();
|
|
if (text.Length > 1 && text.StartsWith('U'))
|
|
{
|
|
string text2 = text.Substring(1);
|
|
string text3 = ((text2 == "568") ? "Patch 7.3 Fantasia" : ((!(text2 == "506")) ? ("U" + text2) : "Patch 7.2 Fantasia"));
|
|
text = text3;
|
|
}
|
|
else
|
|
{
|
|
text = $"Unlock Link {unlockLinkId.Value}";
|
|
}
|
|
questInfo = new UnlockLinkQuestInfo(unlockLinkId, text, 0u, dateTime);
|
|
_logger.LogDebug("Created UnlockLinkQuestInfo for {QuestId} from assembly", elementId2);
|
|
_questData.AddOrReplaceQuestInfo(questInfo);
|
|
goto IL_0173;
|
|
}
|
|
_logger.LogWarning("Not loading unknown quest {QuestId} from assembly: Quest not found in quest data", elementId2);
|
|
goto end_IL_0028;
|
|
IL_0173:
|
|
if (questRoot2.IsSeasonalQuest.HasValue || questRoot2.SeasonalQuestExpiry.HasValue)
|
|
{
|
|
_questData.ApplySeasonalOverride(elementId2, questRoot2.IsSeasonalQuest ?? questInfo.IsSeasonalQuest, dateTime);
|
|
}
|
|
IQuestInfo questInfo2 = _questData.GetQuestInfo(elementId2);
|
|
Quest quest = new Quest
|
|
{
|
|
Id = elementId2,
|
|
Root = questRoot2,
|
|
Info = questInfo2,
|
|
Source = Quest.ESource.Assembly,
|
|
SourcePath = null
|
|
};
|
|
snapshot.Quests[quest.Id] = quest;
|
|
end_IL_0028:;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning("Not loading unknown quest {QuestId} from assembly: {Message}", elementId2, ex.Message);
|
|
}
|
|
}
|
|
_logger.LogDebug("Loaded {Count} quests from assembly", snapshot.Quests.Count);
|
|
}
|
|
|
|
[Conditional("DEBUG")]
|
|
private void LoadQuestsFromProjectDirectory(Snapshot snapshot)
|
|
{
|
|
DirectoryInfo directoryInfo = _pluginInterface.AssemblyLocation.Directory?.Parent?.Parent;
|
|
if (directoryInfo == null)
|
|
{
|
|
return;
|
|
}
|
|
DirectoryInfo directoryInfo2 = new DirectoryInfo(Path.Combine(directoryInfo.FullName, "Paths", "QuestPaths"));
|
|
try
|
|
{
|
|
foreach (string value in ExpansionData.ExpansionFolders.Values)
|
|
{
|
|
LoadFromDirectory(snapshot, new DirectoryInfo(Path.Combine(directoryInfo2.FullName, value)), Quest.ESource.ProjectDirectory, LogLevel.Trace);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
snapshot.Quests.Clear();
|
|
_chatGui.PrintError("Unable to load quests - " + ex.GetType().Name + ": " + ex.Message, "Questionable", 576);
|
|
_logger.LogError(ex, "Failed to load quests from project directory");
|
|
}
|
|
}
|
|
|
|
private void LoadCfcIds(Snapshot snapshot)
|
|
{
|
|
foreach (Quest value2 in snapshot.Quests.Values)
|
|
{
|
|
foreach (QuestSequence item in value2.AllSequences())
|
|
{
|
|
foreach (QuestStep item2 in item.Steps.Where(delegate(QuestStep x)
|
|
{
|
|
EInteractionType interactionType = x.InteractionType;
|
|
return (uint)(interactionType - 19) <= 1u;
|
|
}))
|
|
{
|
|
if (item2 != null && item2.InteractionType == EInteractionType.Duty)
|
|
{
|
|
DutyOptions dutyOptions = item2.DutyOptions;
|
|
if (dutyOptions != null)
|
|
{
|
|
snapshot.ContentFinderConditionIds[dutyOptions.ContentFinderConditionId] = (value2.Id, item2);
|
|
if (DutyRegistry.Duties.TryGetValue(dutyOptions.ContentFinderConditionId, out DutyEntry value) && value.LowPriority)
|
|
{
|
|
snapshot.LowPriorityContentFinderConditionQuests.Add((dutyOptions.ContentFinderConditionId, value2.Id, item.Sequence));
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
if (item2.InteractionType == EInteractionType.SinglePlayerDuty && _territoryData.TryGetContentFinderConditionForSoloInstance(value2.Id, item2.SinglePlayerDutyIndex, out TerritoryData.ContentFinderConditionData contentFinderConditionData))
|
|
{
|
|
snapshot.ContentFinderConditionIds[contentFinderConditionData.ContentFinderConditionId] = (value2.Id, item2);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ValidateQuests()
|
|
{
|
|
_questValidator.Validate(_snapshot.Quests.Values.Where((Quest x) => x.Source != Quest.ESource.Assembly).ToList());
|
|
}
|
|
|
|
private void LoadQuestFromStream(Snapshot snapshot, string fileName, Stream stream, Quest.ESource source, string directoryName, string? sourcePath)
|
|
{
|
|
if (source == Quest.ESource.UserDirectory)
|
|
{
|
|
_logger.LogTrace("Loading quest from '{FileName}'", fileName);
|
|
}
|
|
ElementId elementId = ExtractQuestIdFromName(fileName);
|
|
if (elementId == null)
|
|
{
|
|
return;
|
|
}
|
|
JsonNode jsonNode;
|
|
try
|
|
{
|
|
jsonNode = JsonNode.Parse(stream);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
ValidationIssue validationIssue = new ValidationIssue
|
|
{
|
|
PathType = EPathType.Quest,
|
|
Id = elementId.ToString(),
|
|
Location = null,
|
|
Type = EIssueType.InvalidJsonSyntax,
|
|
Severity = EIssueSeverity.Error,
|
|
Description = "JSON syntax error in " + fileName + ": " + ex.Message
|
|
};
|
|
_questValidator.AddValidationIssue(validationIssue);
|
|
snapshot.FailedLoads[elementId] = new FailedQuestLoad(elementId, fileName, validationIssue.Description, source);
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
List<ValidationIssue> list = _jsonSchemaValidator.ValidateImmediate(elementId, jsonNode);
|
|
if (list.Count > 0)
|
|
{
|
|
foreach (ValidationIssue item in list)
|
|
{
|
|
_questValidator.AddValidationIssue(item);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
catch (Exception ex2)
|
|
{
|
|
_questValidator.AddValidationIssue(new ValidationIssue
|
|
{
|
|
PathType = EPathType.Quest,
|
|
Id = elementId.ToString(),
|
|
Location = null,
|
|
Type = EIssueType.InvalidJsonSchema,
|
|
Severity = EIssueSeverity.Error,
|
|
Description = "Schema validation error: " + (ex2.InnerException?.Message ?? ex2.Message)
|
|
});
|
|
return;
|
|
}
|
|
QuestRoot questRoot;
|
|
try
|
|
{
|
|
questRoot = jsonNode.Deserialize<QuestRoot>();
|
|
}
|
|
catch (JsonException ex3)
|
|
{
|
|
var (location, text) = FormatDeserializationError(ex3);
|
|
_questValidator.AddValidationIssue(new ValidationIssue
|
|
{
|
|
PathType = EPathType.Quest,
|
|
Id = elementId.ToString(),
|
|
Location = location,
|
|
Type = EIssueType.InvalidJsonSchema,
|
|
Severity = EIssueSeverity.Error,
|
|
Description = text
|
|
});
|
|
snapshot.FailedLoads[elementId] = new FailedQuestLoad(elementId, fileName, text, source);
|
|
return;
|
|
}
|
|
if (!_questData.TryGetQuestInfo(elementId, out IQuestInfo questInfo))
|
|
{
|
|
if (!(elementId is UnlockLinkId unlockLinkId))
|
|
{
|
|
_logger.LogWarning("Not loading unknown quest {QuestId} from project file {FileName}", elementId, fileName);
|
|
return;
|
|
}
|
|
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
|
int num = fileNameWithoutExtension.IndexOf('_', StringComparison.Ordinal);
|
|
string name = ((num >= 0 && num + 1 < fileNameWithoutExtension.Length) ? fileNameWithoutExtension.Substring(num + 1) : fileNameWithoutExtension);
|
|
name = NormalizeDerivedName(name);
|
|
uint issuerDataId = 0u;
|
|
string patch = null;
|
|
if (jsonNode is JsonObject jsonObject)
|
|
{
|
|
if (jsonObject.TryGetPropertyValue("DataId", out JsonNode jsonNode2) && jsonNode2 != null)
|
|
{
|
|
try
|
|
{
|
|
issuerDataId = jsonNode2.GetValue<uint>();
|
|
}
|
|
catch (Exception)
|
|
{
|
|
issuerDataId = 0u;
|
|
}
|
|
}
|
|
if (jsonObject.TryGetPropertyValue("Patch", out JsonNode jsonNode3) && jsonNode3 != null)
|
|
{
|
|
try
|
|
{
|
|
patch = jsonNode3.GetValue<string>();
|
|
}
|
|
catch (Exception)
|
|
{
|
|
patch = null;
|
|
}
|
|
}
|
|
}
|
|
DateTime? expiryTime = (questRoot.SeasonalQuestExpiry.HasValue ? new DateTime?(DateTime.SpecifyKind(questRoot.SeasonalQuestExpiry.Value, DateTimeKind.Utc)) : ((DateTime?)null));
|
|
questInfo = new UnlockLinkQuestInfo(unlockLinkId, name, issuerDataId, expiryTime, patch);
|
|
_logger.LogDebug("Created UnlockLinkQuestInfo for {QuestId} from project file '{FileName}'", elementId, fileName);
|
|
_questData.AddOrReplaceQuestInfo(questInfo);
|
|
}
|
|
if (questRoot.IsSeasonalQuest.HasValue || questRoot.SeasonalQuestExpiry.HasValue)
|
|
{
|
|
DateTime? expiry = (questRoot.SeasonalQuestExpiry.HasValue ? new DateTime?(DateTime.SpecifyKind(questRoot.SeasonalQuestExpiry.Value, DateTimeKind.Utc)) : ((DateTime?)null));
|
|
_questData.ApplySeasonalOverride(elementId, questRoot.IsSeasonalQuest ?? questInfo.IsSeasonalQuest, expiry);
|
|
}
|
|
Quest quest = new Quest
|
|
{
|
|
Id = elementId,
|
|
Root = questRoot,
|
|
Info = questInfo,
|
|
Source = source,
|
|
SourcePath = sourcePath
|
|
};
|
|
snapshot.Quests[quest.Id] = quest;
|
|
if (!string.IsNullOrEmpty(directoryName))
|
|
{
|
|
snapshot.QuestFolderNames[elementId] = directoryName;
|
|
}
|
|
}
|
|
|
|
private void LoadFromDirectory(Snapshot snapshot, DirectoryInfo directory, Quest.ESource source, LogLevel logLevel = LogLevel.Debug)
|
|
{
|
|
if (!directory.Exists)
|
|
{
|
|
_logger.LogDebug("Not loading quests from {DirectoryName} (doesn't exist)", directory);
|
|
return;
|
|
}
|
|
if (source == Quest.ESource.UserDirectory)
|
|
{
|
|
_logger.Log(logLevel, "Loading quests from {DirectoryName}", directory);
|
|
}
|
|
FileInfo[] files = directory.GetFiles("*.json");
|
|
foreach (FileInfo fileInfo in files)
|
|
{
|
|
try
|
|
{
|
|
using FileStream stream = fileInfo.OpenRead();
|
|
LoadQuestFromStream(snapshot, fileInfo.Name, stream, source, directory.Name, fileInfo.FullName);
|
|
}
|
|
catch (Exception innerException)
|
|
{
|
|
throw new InvalidDataException("Unable to load file " + fileInfo.FullName, innerException);
|
|
}
|
|
}
|
|
DirectoryInfo[] directories = directory.GetDirectories();
|
|
foreach (DirectoryInfo directory2 in directories)
|
|
{
|
|
LoadFromDirectory(snapshot, directory2, source, logLevel);
|
|
}
|
|
}
|
|
|
|
private static (string? Location, string Description) FormatDeserializationError(JsonException ex)
|
|
{
|
|
string path = ex.Path;
|
|
if (string.IsNullOrEmpty(path))
|
|
{
|
|
return (Location: null, Description: "Invalid value in quest file: " + (ex.InnerException?.Message ?? ex.Message));
|
|
}
|
|
string item = null;
|
|
Match match = Regex.Match(path, "QuestSequence\\[(\\d+)\\]");
|
|
Match match2 = Regex.Match(path, "Steps\\[(\\d+)\\]");
|
|
if (match.Success)
|
|
{
|
|
item = (match2.Success ? ("Seq " + match.Groups[1].Value + ", Step " + match2.Groups[1].Value) : ("Seq " + match.Groups[1].Value));
|
|
}
|
|
Match match3 = Regex.Match(path, "\\.(\\w+)$");
|
|
string text = (match3.Success ? match3.Groups[1].Value : path);
|
|
string text2 = ex.InnerException?.Message ?? "";
|
|
string item2 = (text2.Contains("could not be converted", StringComparison.Ordinal) ? ("'" + text + "' has an invalid value") : ((!text2.Contains("is not supported", StringComparison.Ordinal)) ? ("'" + text + "' could not be read: " + text2) : ("'" + text + "' has an unrecognized value")));
|
|
return (Location: item, Description: item2);
|
|
}
|
|
|
|
private static ElementId? ExtractQuestIdFromName(string resourceName)
|
|
{
|
|
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(resourceName);
|
|
fileNameWithoutExtension = fileNameWithoutExtension.Substring(fileNameWithoutExtension.LastIndexOf('.') + 1);
|
|
if (!fileNameWithoutExtension.Contains('_', StringComparison.Ordinal))
|
|
{
|
|
return null;
|
|
}
|
|
return ElementId.FromString(fileNameWithoutExtension.Split('_', 2)[0]);
|
|
}
|
|
|
|
public bool IsKnownQuest(ElementId questId)
|
|
{
|
|
return _snapshot.Quests.ContainsKey(questId);
|
|
}
|
|
|
|
public bool TryGetQuest(ElementId questId, [NotNullWhen(true)] out Quest? quest)
|
|
{
|
|
return _snapshot.Quests.TryGetValue(questId, out quest);
|
|
}
|
|
|
|
public List<QuestInfo> GetKnownClassJobQuests(EClassJob classJob, bool includeRoleQuests = true)
|
|
{
|
|
Dictionary<ElementId, Quest> quests = _snapshot.Quests;
|
|
List<QuestInfo> list = _questData.GetClassJobQuests(classJob, includeRoleQuests).ToList();
|
|
if (classJob.AsJob() != classJob)
|
|
{
|
|
list.AddRange(_questData.GetClassJobQuests(classJob.AsJob(), includeRoleQuests));
|
|
}
|
|
return list.Where((QuestInfo x) => quests.ContainsKey(x.QuestId)).ToList();
|
|
}
|
|
|
|
public IEnumerable<ElementId> GetAllQuestIds()
|
|
{
|
|
return _snapshot.Quests.Keys;
|
|
}
|
|
|
|
public bool TryGetQuestFolderName(ElementId questId, [NotNullWhen(true)] out string? folderName)
|
|
{
|
|
return _snapshot.QuestFolderNames.TryGetValue(questId, out folderName);
|
|
}
|
|
|
|
private static string NormalizeDerivedName(string name)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
return name ?? string.Empty;
|
|
}
|
|
name = name.Replace("_", " ", StringComparison.OrdinalIgnoreCase);
|
|
name = Regex.Replace(name, "\\s+", " ");
|
|
name = Regex.Replace(name, "\\b(Patch)\\s+(\\d+)\\s+(\\d+)\\b", "$1 $2.$3", RegexOptions.IgnoreCase);
|
|
return name;
|
|
}
|
|
}
|