#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 Quests { get; } public Dictionary FailedLoads { get; } public Dictionary ContentFinderConditionIds { get; } public List<(uint ContentFinderConditionId, ElementId QuestId, int Sequence)> LowPriorityContentFinderConditionQuests { get; } public Dictionary QuestFolderNames { get; } public int Count { get; set; } public Snapshot() { Quests = new Dictionary(); FailedLoads = new Dictionary(); ContentFinderConditionIds = new Dictionary(); LowPriorityContentFinderConditionQuests = new List<(uint, ElementId, int)>(); QuestFolderNames = new Dictionary(); } public Snapshot(Snapshot other) { Quests = new Dictionary(other.Quests); FailedLoads = new Dictionary(other.FailedLoads); ContentFinderConditionIds = new Dictionary(other.ContentFinderConditionIds); LowPriorityContentFinderConditionQuests = new List<(uint, ElementId, int)>(other.LowPriorityContentFinderConditionQuests); QuestFolderNames = new Dictionary(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 _logger; private readonly TerritoryData _territoryData; private readonly IChatGui _chatGui; private readonly ICallGateProvider _reloadDataIpc; private volatile Snapshot _snapshot = new Snapshot(); public IEnumerable AllQuests => _snapshot.Quests.Values; public int Count => _snapshot.Count; public IReadOnlyDictionary 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 logger, TerritoryData territoryData, IChatGui chatGui, IFramework framework) { _pluginInterface = pluginInterface; _questData = questData; _questValidator = questValidator; _jsonSchemaValidator = jsonSchemaValidator; _logger = logger; _territoryData = territoryData; _chatGui = chatGui; _reloadDataIpc = _pluginInterface.GetIpcProvider("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 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 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(); } 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(); } catch (Exception) { issuerDataId = 0u; } } if (jsonObject.TryGetPropertyValue("Patch", out JsonNode jsonNode3) && jsonNode3 != null) { try { patch = jsonNode3.GetValue(); } 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 GetKnownClassJobQuests(EClassJob classJob, bool includeRoleQuests = true) { Dictionary quests = _snapshot.Quests; List 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 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; } }