#define RELEASE using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; 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 { 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 readonly Dictionary _quests = new Dictionary(); private readonly Dictionary _contentFinderConditionIds = new Dictionary(); private readonly List<(uint ContentFinderConditionId, ElementId QuestId, int Sequence)> _lowPriorityContentFinderConditionQuests = new List<(uint, ElementId, int)>(); private readonly Dictionary _questFolderNames = new Dictionary(); public IEnumerable AllQuests => _quests.Values; public int Count => _quests.Count>((KeyValuePair x) => !x.Value.Root.Disabled); public int ValidationIssueCount => _questValidator.IssueCount; public int ValidationErrorCount => _questValidator.ErrorCount; public IReadOnlyList<(uint ContentFinderConditionId, ElementId QuestId, int Sequence)> LowPriorityContentFinderConditionQuests => _lowPriorityContentFinderConditionQuests; public event EventHandler? Reloaded; public QuestRegistry(IDalamudPluginInterface pluginInterface, QuestData questData, QuestValidator questValidator, JsonSchemaValidator jsonSchemaValidator, ILogger logger, TerritoryData territoryData, IChatGui chatGui) { _pluginInterface = pluginInterface; _questData = questData; _questValidator = questValidator; _jsonSchemaValidator = jsonSchemaValidator; _logger = logger; _territoryData = territoryData; _chatGui = chatGui; _reloadDataIpc = _pluginInterface.GetIpcProvider("Questionable.ReloadData"); } public void Reload() { _questValidator.Reset(); _quests.Clear(); _contentFinderConditionIds.Clear(); _lowPriorityContentFinderConditionQuests.Clear(); _questFolderNames.Clear(); LoadQuestsFromAssembly(); try { LoadFromDirectory(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(); ValidateQuests(); this.Reloaded?.Invoke(this, EventArgs.Empty); try { _reloadDataIpc.SendMessage(); } catch (Exception exception2) { _logger.LogWarning(exception2, "Error during Reload.SendMessage IPC"); } _logger.LogInformation("Loaded {Count} quests in total", _quests.Count); } [Conditional("RELEASE")] private void LoadQuestsFromAssembly() { _logger.LogInformation("Loading quests from assembly"); foreach (var (elementId2, questRoot2) in AssemblyQuestLoader.GetQuests()) { try { bool? flag = null; DateTime? dateTime = null; bool flag2 = false; bool flag3 = false; try { flag = questRoot2.IsSeasonalQuest; flag2 = flag.HasValue; if (questRoot2.SeasonalQuestExpiry.HasValue) { dateTime = DateTime.SpecifyKind(questRoot2.SeasonalQuestExpiry.Value, DateTimeKind.Utc); flag3 = true; } } catch (Exception exception) { _logger.LogWarning(exception, "Failed to read seasonal fields from embedded QuestRoot for {QuestId}", elementId2); } if (_questData.TryGetQuestInfo(elementId2, out IQuestInfo questInfo)) { goto IL_01c8; } 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_01c8; } _logger.LogWarning("Not loading unknown quest {QuestId} from assembly: Quest not found in quest data", elementId2); goto end_IL_003d; IL_01c8: if (flag2 || flag3) { bool flag4 = flag ?? questInfo.IsSeasonalQuest; _questData.ApplySeasonalOverride(elementId2, flag4, dateTime); _logger.LogDebug("Applied seasonal override for quest {QuestId} from assembly: IsSeasonal={IsSeasonal}, Expiry={Expiry}", elementId2, flag4, dateTime?.ToString("o") ?? "(null)"); } IQuestInfo questInfo2 = _questData.GetQuestInfo(elementId2); Quest quest = new Quest { Id = elementId2, Root = questRoot2, Info = questInfo2, Source = Quest.ESource.Assembly }; _quests[quest.Id] = quest; end_IL_003d:; } catch (Exception ex) { _logger.LogWarning("Not loading unknown quest {QuestId} from assembly: {Message}", elementId2, ex.Message); } } _logger.LogInformation("Loaded {Count} quests from assembly", _quests.Count); } [Conditional("DEBUG")] private void LoadQuestsFromProjectDirectory() { DirectoryInfo directoryInfo = _pluginInterface.AssemblyLocation.Directory?.Parent?.Parent; if (directoryInfo == null) { return; } DirectoryInfo directoryInfo2 = new DirectoryInfo(Path.Combine(directoryInfo.FullName, "QuestPaths")); if (!directoryInfo2.Exists) { return; } try { foreach (string value in ExpansionData.ExpansionFolders.Values) { LoadFromDirectory(new DirectoryInfo(Path.Combine(directoryInfo2.FullName, value)), Quest.ESource.ProjectDirectory, LogLevel.Trace); } } catch (Exception ex) { _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() { foreach (Quest value in _quests.Values) { foreach (QuestSequence item in value.AllSequences()) { foreach (QuestStep item2 in item.Steps.Where(delegate(QuestStep x) { EInteractionType interactionType = x.InteractionType; return (uint)(interactionType - 18) <= 1u; })) { if (item2 != null && item2.InteractionType == EInteractionType.Duty) { DutyOptions dutyOptions = item2.DutyOptions; if (dutyOptions != null) { _contentFinderConditionIds[dutyOptions.ContentFinderConditionId] = (value.Id, item2); if (dutyOptions.LowPriority) { _lowPriorityContentFinderConditionQuests.Add((dutyOptions.ContentFinderConditionId, value.Id, item.Sequence)); } continue; } } if (item2.InteractionType == EInteractionType.SinglePlayerDuty && _territoryData.TryGetContentFinderConditionForSoloInstance(value.Id, item2.SinglePlayerDutyIndex, out TerritoryData.ContentFinderConditionData contentFinderConditionData)) { _contentFinderConditionIds[contentFinderConditionData.ContentFinderConditionId] = (value.Id, item2); } } } } } private void ValidateQuests() { _questValidator.Validate(_quests.Values.Where((Quest x) => x.Source != Quest.ESource.Assembly).ToList()); } private void LoadQuestFromStream(string fileName, Stream stream, Quest.ESource source, string directoryName) { 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 issue = new ValidationIssue { ElementId = elementId, Sequence = null, Step = null, Type = EIssueType.InvalidJsonSyntax, Severity = EIssueSeverity.Error, Description = $"JSON parsing error in file '{fileName}': {ex.Message}\n\nThis usually indicates a syntax error such as:\n\ufffd Missing comma between properties\n\ufffd Unclosed quotes or brackets\n\ufffd Invalid escape sequences\n\ufffd Trailing commas where not allowed\n\nPlease check the JSON syntax around the indicated position." }; _questValidator.AddValidationIssue(issue); return; } _jsonSchemaValidator.Enqueue(elementId, jsonNode); bool? flag = null; DateTime? dateTime = null; bool flag2 = false; bool flag3 = false; if (jsonNode is JsonObject jsonObject) { if (jsonObject.TryGetPropertyValue("IsSeasonalQuest", out JsonNode jsonNode2) && jsonNode2 != null) { try { flag = jsonNode2.GetValue(); flag2 = true; _logger.LogDebug("Quest {QuestId}: parsed IsSeasonalQuest override = {IsSeasonal}", elementId, flag); } catch (Exception exception) { _logger.LogWarning(exception, "Quest {QuestId}: failed to parse IsSeasonalQuest from JSON", elementId); } } if (jsonObject.TryGetPropertyValue("SeasonalQuestExpiry", out JsonNode jsonNode3) && jsonNode3 != null) { try { string value = jsonNode3.GetValue(); if (!string.IsNullOrEmpty(value)) { dateTime = ((!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var result)) ? new DateTime?(DateTime.Parse(value, null, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal)) : new DateTime?(DateTime.SpecifyKind(result, DateTimeKind.Utc))); flag3 = true; _logger.LogDebug("Quest {QuestId}: parsed SeasonalQuestExpiry override = {Expiry}", elementId, dateTime); } } catch (Exception exception2) { _logger.LogWarning(exception2, "Quest {QuestId}: failed to parse SeasonalQuestExpiry from JSON", elementId); } } } QuestRoot root = jsonNode.Deserialize(); 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 name; try { string text = fileName.Substring(0, fileName.Length - ".json".Length); int num = text.IndexOf('_', StringComparison.Ordinal); string text2; if (num < 0 || num + 1 >= text.Length) { text2 = text; } else { string text3 = text; int num2 = num + 1; text2 = text3.Substring(num2, text3.Length - num2); } name = text2; } catch { name = fileName.Substring(0, fileName.Length - ".json".Length); } name = NormalizeDerivedName(name); uint issuerDataId = 0u; string patch = null; if (jsonNode is JsonObject jsonObject2) { if (jsonObject2.TryGetPropertyValue("DataId", out JsonNode jsonNode4) && jsonNode4 != null) { try { issuerDataId = jsonNode4.GetValue(); } catch { issuerDataId = 0u; } } if (jsonObject2.TryGetPropertyValue("Patch", out JsonNode jsonNode5) && jsonNode5 != null) { try { patch = jsonNode5.GetValue(); } catch { patch = null; } } } questInfo = new UnlockLinkQuestInfo(unlockLinkId, name, issuerDataId, dateTime, patch); _logger.LogDebug("Created UnlockLinkQuestInfo for {QuestId} from project file '{FileName}'", elementId, fileName); _questData.AddOrReplaceQuestInfo(questInfo); } if ((flag2 || flag3) && _questData.TryGetQuestInfo(elementId, out IQuestInfo questInfo2)) { _questData.ApplySeasonalOverride(elementId, flag ?? questInfo2.IsSeasonalQuest, dateTime); } Quest quest = new Quest { Id = elementId, Root = root, Info = questInfo, Source = source }; _quests[quest.Id] = quest; if (!string.IsNullOrEmpty(directoryName)) { _questFolderNames[elementId] = directoryName; } } private void LoadFromDirectory(DirectoryInfo directory, Quest.ESource source, LogLevel logLevel = LogLevel.Information) { if (!directory.Exists) { _logger.LogInformation("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 = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read); LoadQuestFromStream(fileInfo.Name, stream, source, directory.Name); } catch (Exception innerException) { throw new InvalidDataException("Unable to load file " + fileInfo.FullName, innerException); } } DirectoryInfo[] directories = directory.GetDirectories(); foreach (DirectoryInfo directory2 in directories) { LoadFromDirectory(directory2, source, logLevel); } } private static ElementId? ExtractQuestIdFromName(string resourceName) { string text = resourceName.Substring(0, resourceName.Length - ".json".Length); text = text.Substring(text.LastIndexOf('.') + 1); if (!text.Contains('_', StringComparison.Ordinal)) { return null; } return ElementId.FromString(text.Split('_', 2)[0]); } public bool IsKnownQuest(ElementId questId) { return _quests.ContainsKey(questId); } public bool TryGetQuest(ElementId questId, [NotNullWhen(true)] out Quest? quest) { return _quests.TryGetValue(questId, out quest); } public List GetKnownClassJobQuests(EClassJob classJob, bool includeRoleQuests = true) { List list = _questData.GetClassJobQuests(classJob, includeRoleQuests).ToList(); if (classJob.AsJob() != classJob) { list.AddRange(_questData.GetClassJobQuests(classJob.AsJob(), includeRoleQuests)); } return list.Where((QuestInfo x) => IsKnownQuest(x.QuestId)).ToList(); } public bool TryGetDutyByContentFinderConditionId(uint cfcId, [NotNullWhen(true)] out DutyOptions? dutyOptions) { if (_contentFinderConditionIds.TryGetValue(cfcId, out (ElementId, QuestStep) value)) { dutyOptions = value.Item2.DutyOptions; return dutyOptions != null; } dutyOptions = null; return false; } public IEnumerable GetAllQuestIds() { return _quests.Keys; } public bool TryGetQuestFolderName(ElementId questId, [NotNullWhen(true)] out string? folderName) { return _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; } }