qstbak/Questionable/Questionable.Controller/QuestRegistry.cs
2026-08-17 20:25:32 +10:00

573 lines
18 KiB
C#

#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
{
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 readonly Dictionary<ElementId, Quest> _quests = new Dictionary<ElementId, Quest>();
private readonly Dictionary<uint, (ElementId QuestId, QuestStep Step)> _contentFinderConditionIds = new Dictionary<uint, (ElementId, QuestStep)>();
private readonly List<(uint ContentFinderConditionId, ElementId QuestId, int Sequence)> _lowPriorityContentFinderConditionQuests = new List<(uint, ElementId, int)>();
private readonly Dictionary<ElementId, string> _questFolderNames = new Dictionary<ElementId, string>();
private readonly Dictionary<ElementId, FailedQuestLoad> _failedLoads = new Dictionary<ElementId, FailedQuestLoad>();
public IEnumerable<Quest> AllQuests => _quests.Values;
public IReadOnlyDictionary<ElementId, FailedQuestLoad> FailedLoads => _failedLoads;
public int Count => _quests.Count<KeyValuePair<ElementId, Quest>>((KeyValuePair<ElementId, Quest> 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<QuestRegistry> logger, TerritoryData territoryData, IChatGui chatGui)
{
_pluginInterface = pluginInterface;
_questData = questData;
_questValidator = questValidator;
_jsonSchemaValidator = jsonSchemaValidator;
_logger = logger;
_territoryData = territoryData;
_chatGui = chatGui;
_reloadDataIpc = _pluginInterface.GetIpcProvider<object>("Questionable.ReloadData");
}
public void Reload()
{
_questValidator.Reset();
_quests.Clear();
_failedLoads.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_01ae;
}
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);
_questData.AddOrReplaceQuestInfo(questInfo);
goto IL_01ae;
}
_logger.LogWarning("Not loading unknown quest {QuestId} from assembly: Quest not found in quest data", elementId2);
goto end_IL_003d;
IL_01ae:
if (flag2 || flag3)
{
bool isSeasonal = flag ?? questInfo.IsSeasonalQuest;
_questData.ApplySeasonalOverride(elementId2, isSeasonal, dateTime);
}
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, string? filePath = null)
{
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);
string text = FindDuplicateKey(jsonNode);
if (text != null)
{
string errorMessage = "Duplicate JSON property '" + text + "'. Remove the duplicate key.";
_questValidator.AddValidationIssue(new ValidationIssue
{
ElementId = elementId,
Sequence = null,
Step = null,
Type = EIssueType.InvalidJsonSyntax,
Severity = EIssueSeverity.Error,
Description = $"Duplicate JSON property '{text}' in file '{fileName}'. Remove the duplicate key."
});
_failedLoads[elementId] = new FailedQuestLoad(elementId, filePath, errorMessage, source);
return;
}
}
catch (JsonException ex)
{
string errorMessage2 = "JSON syntax error: " + ex.Message;
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■ Missing comma between properties\n■ Unclosed quotes or brackets\n■ Invalid escape sequences\n■ Trailing commas where not allowed\n\nPlease check the JSON syntax around the indicated position."
};
_questValidator.AddValidationIssue(issue);
_failedLoads[elementId] = new FailedQuestLoad(elementId, filePath, errorMessage2, source);
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<bool>();
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<string>();
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;
try
{
root = jsonNode.Deserialize<QuestRoot>();
}
catch (Exception ex2)
{
string errorMessage3 = "Failed to deserialize quest data: " + ex2.Message;
_questValidator.AddValidationIssue(new ValidationIssue
{
ElementId = elementId,
Sequence = null,
Step = null,
Type = EIssueType.InvalidJsonSchema,
Severity = EIssueSeverity.Error,
Description = "Failed to deserialize '" + fileName + "': " + ex2.Message
});
_failedLoads[elementId] = new FailedQuestLoad(elementId, filePath, errorMessage3, 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 name;
try
{
string text2 = fileName.Substring(0, fileName.Length - ".json".Length);
int num = text2.IndexOf('_', StringComparison.Ordinal);
name = ((num >= 0 && num + 1 < text2.Length) ? text2.Substring(num + 1) : 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<uint>();
}
catch
{
issuerDataId = 0u;
}
}
if (jsonObject2.TryGetPropertyValue("Patch", out JsonNode jsonNode5) && jsonNode5 != null)
{
try
{
patch = jsonNode5.GetValue<string>();
}
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, fileInfo.FullName);
}
catch (Exception ex)
{
ElementId elementId = ExtractQuestIdFromName(fileInfo.Name);
string errorMessage = "Unexpected error loading file: " + ex.Message;
_logger.LogError(ex, "Failed to load quest from '{FileName}', skipping", fileInfo.FullName);
if (elementId != null)
{
_failedLoads[elementId] = new FailedQuestLoad(elementId, fileInfo.FullName, errorMessage, source);
}
}
}
DirectoryInfo[] directories = directory.GetDirectories();
foreach (DirectoryInfo directory2 in directories)
{
LoadFromDirectory(directory2, source, logLevel);
}
}
private static string? FindDuplicateKey(JsonNode? node)
{
if (node is JsonObject jsonObject)
{
try
{
HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal);
foreach (var (text2, node2) in jsonObject)
{
if (!hashSet.Add(text2))
{
return text2;
}
string text3 = FindDuplicateKey(node2);
if (text3 != null)
{
return text3;
}
}
}
catch (ArgumentException ex) when (ex.Message.Contains("same key", StringComparison.OrdinalIgnoreCase))
{
Match match = Regex.Match(ex.Message, "Key: (.+)");
return match.Success ? match.Groups[1].Value.Trim() : "unknown";
}
}
else if (node is JsonArray jsonArray)
{
foreach (JsonNode item in jsonArray)
{
string text4 = FindDuplicateKey(item);
if (text4 != null)
{
return text4;
}
}
}
return null;
}
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 IsFailedLoad(ElementId questId)
{
return _failedLoads.ContainsKey(questId);
}
public bool TryGetQuest(ElementId questId, [NotNullWhen(true)] out Quest? quest)
{
return _quests.TryGetValue(questId, out quest);
}
public List<QuestInfo> GetKnownClassJobQuests(EClassJob classJob, bool includeRoleQuests = true)
{
List<QuestInfo> 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<ElementId> 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;
}
}