From a0e675cbdcdca1f048c1acbc379e8dcfca5e07d6 Mon Sep 17 00:00:00 2001 From: Liza Carvelli Date: Mon, 15 Jul 2024 03:05:37 +0200 Subject: [PATCH] Add basic quest validation --- .../5039_Traveler to the Rescue.json | 10 -- .../Urqopacha/5038_In Pursuit of Mezcal.json | 2 +- .../5007_Sights of the West and Beyond.json | 2 +- .../Instant/5008_Dawn of a New Deal.json | 2 +- QuestPaths/AssemblyQuestLoader.cs | 9 +- Questionable/Controller/QuestRegistry.cs | 158 +++++++++++++++--- 6 files changed, 147 insertions(+), 36 deletions(-) diff --git a/QuestPaths/7.x - Dawntrail/Aether Currents/Urqopacha/5039_Traveler to the Rescue.json b/QuestPaths/7.x - Dawntrail/Aether Currents/Urqopacha/5039_Traveler to the Rescue.json index c9da7b591..9dd6fa0c4 100644 --- a/QuestPaths/7.x - Dawntrail/Aether Currents/Urqopacha/5039_Traveler to the Rescue.json +++ b/QuestPaths/7.x - Dawntrail/Aether Currents/Urqopacha/5039_Traveler to the Rescue.json @@ -65,16 +65,6 @@ "TerritoryId": 1187, "InteractionType": "CompleteQuest", "AetheryteShortcut": "Urqopacha - Wachunpelo" - }, - { - "DataId": 1050684, - "Position": { - "X": 391.37854, - "Y": -156.07434, - "Z": -388.50995 - }, - "TerritoryId": 1187, - "InteractionType": "CompleteQuest" } ] } diff --git a/QuestPaths/7.x - Dawntrail/Side Quests/Urqopacha/5038_In Pursuit of Mezcal.json b/QuestPaths/7.x - Dawntrail/Side Quests/Urqopacha/5038_In Pursuit of Mezcal.json index e463241df..44f4d8a73 100644 --- a/QuestPaths/7.x - Dawntrail/Side Quests/Urqopacha/5038_In Pursuit of Mezcal.json +++ b/QuestPaths/7.x - Dawntrail/Side Quests/Urqopacha/5038_In Pursuit of Mezcal.json @@ -50,7 +50,7 @@ }, "StopDistance": 0.5, "TerritoryId": 1187, - "InteractionType": "AcceptQuest", + "InteractionType": "CompleteQuest", "AetheryteShortcut": "Urqopacha - Wachunpelo", "Fly": true } diff --git a/QuestPaths/7.x - Dawntrail/Unlocks/Instant/5007_Sights of the West and Beyond.json b/QuestPaths/7.x - Dawntrail/Unlocks/Instant/5007_Sights of the West and Beyond.json index cb2725b35..fd2ab340b 100644 --- a/QuestPaths/7.x - Dawntrail/Unlocks/Instant/5007_Sights of the West and Beyond.json +++ b/QuestPaths/7.x - Dawntrail/Unlocks/Instant/5007_Sights of the West and Beyond.json @@ -13,7 +13,7 @@ "Z": -52.99463 }, "TerritoryId": 1186, - "InteractionType": "Interact", + "InteractionType": "AcceptQuest", "Comment": "Quest is completed instantly" } ] diff --git a/QuestPaths/7.x - Dawntrail/Unlocks/Instant/5008_Dawn of a New Deal.json b/QuestPaths/7.x - Dawntrail/Unlocks/Instant/5008_Dawn of a New Deal.json index 720a825ff..4cc2f982f 100644 --- a/QuestPaths/7.x - Dawntrail/Unlocks/Instant/5008_Dawn of a New Deal.json +++ b/QuestPaths/7.x - Dawntrail/Unlocks/Instant/5008_Dawn of a New Deal.json @@ -13,7 +13,7 @@ "Z": -38.132385 }, "TerritoryId": 1186, - "InteractionType": "Interact", + "InteractionType": "AcceptQuest", "Comment": "Quest is completed instantly" } ] diff --git a/QuestPaths/AssemblyQuestLoader.cs b/QuestPaths/AssemblyQuestLoader.cs index 4abfb4c7c..b0b8efc01 100644 --- a/QuestPaths/AssemblyQuestLoader.cs +++ b/QuestPaths/AssemblyQuestLoader.cs @@ -1,11 +1,14 @@ using System.Collections.Generic; using Questionable.Model.V1; -#if RELEASE namespace Questionable.QuestPaths; public static partial class AssemblyQuestLoader { - public static IReadOnlyDictionary GetQuests() => Quests; -} + public static IReadOnlyDictionary GetQuests() => +#if RELEASE + Quests; +#else + new Dictionary(); #endif +} diff --git a/Questionable/Controller/QuestRegistry.cs b/Questionable/Controller/QuestRegistry.cs index b7450c534..f8a412539 100644 --- a/Questionable/Controller/QuestRegistry.cs +++ b/Questionable/Controller/QuestRegistry.cs @@ -1,13 +1,17 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.IO; +using System.Numerics; using System.Text.Json; +using System.Threading.Tasks; using Dalamud.Plugin; using Dalamud.Plugin.Services; using Microsoft.Extensions.Logging; +using Questionable.Controller.Utils; using Questionable.Data; using Questionable.Model; using Questionable.Model.V1; @@ -18,15 +22,17 @@ internal sealed class QuestRegistry { private readonly IDalamudPluginInterface _pluginInterface; private readonly QuestData _questData; + private readonly IChatGui _chatGui; private readonly ILogger _logger; private readonly Dictionary _quests = new(); - public QuestRegistry(IDalamudPluginInterface pluginInterface, QuestData questData, + public QuestRegistry(IDalamudPluginInterface pluginInterface, QuestData questData, IChatGui chatGui, ILogger logger) { _pluginInterface = pluginInterface; _questData = questData; + _chatGui = chatGui; _logger = logger; } @@ -36,7 +42,26 @@ internal sealed class QuestRegistry { _quests.Clear(); -#if RELEASE + LoadQuestsFromAssembly(); + LoadQuestsFromProjectDirectory(); + + try + { + LoadFromDirectory(new DirectoryInfo(Path.Combine(_pluginInterface.ConfigDirectory.FullName, "Quests"))); + } + catch (Exception e) + { + _logger.LogError(e, + "Failed to load all quests from user directory (some may have been successfully loaded)"); + } + + ValidateQuests(); + _logger.LogInformation("Loaded {Count} quests", _quests.Count); + } + + [Conditional("RELEASE")] + private void LoadQuestsFromAssembly() + { _logger.LogInformation("Loading quests from assembly"); foreach ((ushort questId, QuestRoot questRoot) in QuestPaths.AssemblyQuestLoader.GetQuests()) @@ -49,7 +74,11 @@ internal sealed class QuestRegistry }; _quests[questId] = quest; } -#else + } + + [Conditional("DEBUG")] + private void LoadQuestsFromProjectDirectory() + { DirectoryInfo? solutionDirectory = _pluginInterface.AssemblyLocation.Directory?.Parent?.Parent; if (solutionDirectory != null) { @@ -75,27 +104,116 @@ internal sealed class QuestRegistry } } } -#endif + } - try + [Conditional("DEBUG")] + private void ValidateQuests() + { + Task.Run(() => { - LoadFromDirectory(new DirectoryInfo(Path.Combine(_pluginInterface.ConfigDirectory.FullName, "Quests"))); - } - catch (Exception e) - { - _logger.LogError(e, "Failed to load all quests from user directory (some may have been successfully loaded)"); - } + try + { + int foundProblems = 0; + foreach (var quest in _quests.Values) + { + int missingSteps = quest.Root.QuestSequence.Where(x => x.Sequence < 255).Max(x => x.Sequence) - + quest.Root.QuestSequence.Count(x => x.Sequence < 255) + 1; + if (missingSteps != 0) + { + _logger.LogWarning("Quest has missing steps: {QuestId} / {QuestName} → {Count}", quest.QuestId, + quest.Info.Name, missingSteps); + ++foundProblems; + } -#if !RELEASE - foreach (var quest in _quests.Values) - { - int missingSteps = quest.Root.QuestSequence.Where(x => x.Sequence < 255).Max(x => x.Sequence) - quest.Root.QuestSequence.Count(x => x.Sequence < 255) + 1; - if (missingSteps != 0) - _logger.LogWarning("Quest has missing steps: {QuestId} / {QuestName} → {Count}", quest.QuestId, quest.Info.Name, missingSteps); - } -#endif + var totalSequenceCount = quest.Root.QuestSequence.Count; + var distinctSequenceCount = quest.Root.QuestSequence.Select(x => x.Sequence).Distinct().Count(); + if (totalSequenceCount != distinctSequenceCount) + { + _logger.LogWarning("Quest has duplicate sequence numbers: {QuestId} / {QuestName}", quest.QuestId, + quest.Info.Name); + ++foundProblems; + } - _logger.LogInformation("Loaded {Count} quests", _quests.Count); + foreach (var sequence in quest.Root.QuestSequence) + { + if (sequence.Sequence == 0 && + sequence.Steps.LastOrDefault()?.InteractionType != EInteractionType.AcceptQuest) + { + _logger.LogWarning( + "Quest likely has AcceptQuest configured wrong: {QuestId} / {QuestName} → {Sequence} / {Step}", + quest.QuestId, quest.Info.Name, sequence.Sequence, sequence.Steps.Count - 1); + ++foundProblems; + } + else if (sequence.Sequence == 255 && + sequence.Steps.LastOrDefault()?.InteractionType != EInteractionType.CompleteQuest) + { + _logger.LogWarning( + "Quest likely has CompleteQuest configured wrong: {QuestId} / {QuestName} → {Sequence} / {Step}", + quest.QuestId, quest.Info.Name, sequence.Sequence, sequence.Steps.Count - 1); + ++foundProblems; + } + + + var acceptQuestSteps = sequence.Steps + .Where(x => x is { InteractionType: EInteractionType.AcceptQuest, PickupQuestId: null }) + .Where(x => sequence.Sequence != 0 || x != sequence.Steps.Last()); + foreach (var step in acceptQuestSteps) + { + _logger.LogWarning( + "Quest has unexpected AcceptQuest steps: {QuestId} / {QuestName} → {Sequence} / {Step}", + quest.QuestId, quest.Info.Name, sequence.Sequence, sequence.Steps.IndexOf(step)); + ++foundProblems; + } + + var completeQuestSteps = sequence.Steps + .Where(x => x is { InteractionType: EInteractionType.CompleteQuest, TurnInQuestId: null }) + .Where(x => sequence.Sequence != 255 || x != sequence.Steps.Last()); + foreach (var step in completeQuestSteps) + { + _logger.LogWarning( + "Quest has unexpected CompleteQuest steps: {QuestId} / {QuestName} → {Sequence} / {Step}", + quest.QuestId, quest.Info.Name, sequence.Sequence, sequence.Steps.IndexOf(step)); + ++foundProblems; + } + + var completionFlags = sequence.Steps.Select(x => x.CompletionQuestVariablesFlags) + .Where(QuestWorkUtils.HasCompletionFlags) + .GroupBy(x => + { + return Enumerable.Range(0, 6).Select(y => + { + short? value = x[y]; + if (value == null || value.Value < 0) + return (long)0; + return (long)BitOperations.RotateLeft((ulong)value.Value, 8 * y); + }) + .Sum(); + }) + .Where(x => x.Key != 0) + .Where(x => x.Count() > 1); + foreach (var duplicate in completionFlags) + { + _logger.LogWarning( + "Quest step has duplicate completion flags: {QuestId} / {QuestName} → {Sequence} → {Flags}", + quest.QuestId, quest.Info.Name, sequence.Sequence, string.Join(", ", duplicate.First())); + ++foundProblems; + } + } + } + + if (foundProblems > 0) + { + _chatGui.Print( + $"[Questionable] Quest validation has found {foundProblems} problems. Check the log for details."); + } + } + catch (Exception e) + { + _logger.LogError(e, "Unable to validate quests"); + _chatGui.PrintError( + $"[Questionable] Unable to validate quests. Check the log for details."); + } + }); }