Clean up quest validation
authorLiza Carvelli <liza@carvel.li>
Mon, 15 Jul 2024 22:18:10 +0000 (00:18 +0200)
committerLiza Carvelli <liza@carvel.li>
Mon, 15 Jul 2024 22:18:10 +0000 (00:18 +0200)
15 files changed:
Questionable/Controller/QuestRegistry.cs
Questionable/DalamudInitializer.cs
Questionable/GameFunctions.cs
Questionable/QuestionablePlugin.cs
Questionable/Validation/EIssueSeverity.cs [new file with mode: 0644]
Questionable/Validation/IQuestValidator.cs [new file with mode: 0644]
Questionable/Validation/QuestValidator.cs [new file with mode: 0644]
Questionable/Validation/ValidationIssue.cs [new file with mode: 0644]
Questionable/Validation/Validators/BasicSequenceValidator.cs [new file with mode: 0644]
Questionable/Validation/Validators/CompletionFlagsValidator.cs [new file with mode: 0644]
Questionable/Validation/Validators/QuestDisabledValidator.cs [new file with mode: 0644]
Questionable/Validation/Validators/UniqueStartStopValidator.cs [new file with mode: 0644]
Questionable/Windows/QuestSelectionWindow.cs
Questionable/Windows/QuestValidationWindow.cs [new file with mode: 0644]
Questionable/Windows/QuestWindow.cs

index 96d5c8478cc6bccc61c9dd0e5dc9f6ae99f313e9..d836b21ebdf227007105080858f202191e35e590 100644 (file)
@@ -15,6 +15,7 @@ using Questionable.Controller.Utils;
 using Questionable.Data;
 using Questionable.Model;
 using Questionable.Model.V1;
+using Questionable.Validation;
 
 namespace Questionable.Controller;
 
@@ -23,21 +24,24 @@ internal sealed class QuestRegistry
     private readonly IDalamudPluginInterface _pluginInterface;
     private readonly QuestData _questData;
     private readonly IChatGui _chatGui;
+    private readonly QuestValidator _questValidator;
     private readonly ILogger<QuestRegistry> _logger;
 
     private readonly Dictionary<ushort, Quest> _quests = new();
 
     public QuestRegistry(IDalamudPluginInterface pluginInterface, QuestData questData, IChatGui chatGui,
-        ILogger<QuestRegistry> logger)
+        QuestValidator questValidator, ILogger<QuestRegistry> logger)
     {
         _pluginInterface = pluginInterface;
         _questData = questData;
         _chatGui = chatGui;
+        _questValidator = questValidator;
         _logger = logger;
     }
 
     public IEnumerable<Quest> AllQuests => _quests.Values;
     public int Count => _quests.Count;
+    public int ValidationIssueCount => _questValidator.IssueCount;
 
     public void Reload()
     {
@@ -110,113 +114,8 @@ internal sealed class QuestRegistry
     [Conditional("DEBUG")]
     private void ValidateQuests()
     {
-        Task.Run(() =>
-        {
-            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;
-                    }
-
-                    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;
-                    }
-
-                    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.");
-            }
-        });
+        _questValidator.ClearIssues();
+        _questValidator.Validate(_quests.Values);
     }
 
 
@@ -233,13 +132,6 @@ internal sealed class QuestRegistry
             Root = JsonSerializer.Deserialize<QuestRoot>(stream)!,
             Info = _questData.GetQuestInfo(questId.Value),
         };
-        if (quest.Root.Disabled)
-        {
-            _logger.LogWarning("Quest {QuestId} / {QuestName} is disabled and won't be loaded", questId,
-                quest.Info.Name);
-            return;
-        }
-
         _quests[questId.Value] = quest;
     }
 
@@ -281,8 +173,8 @@ internal sealed class QuestRegistry
         return ushort.Parse(parts[0], CultureInfo.InvariantCulture);
     }
 
-    public bool IsKnownQuest(ushort questId) => _quests.ContainsKey(questId);
+    public bool IsKnownQuest(ushort questId) => TryGetQuest(questId, out _);
 
     public bool TryGetQuest(ushort questId, [NotNullWhen(true)] out Quest? quest)
-        => _quests.TryGetValue(questId, out quest);
+        => _quests.TryGetValue(questId, out quest) && !quest.Root.Disabled;
 }
index fa2cfed83fa1474249012d4456a14c129b16a672..d2cf7af44633fc11f0f9397ba9706671e38e1de0 100644 (file)
@@ -29,7 +29,8 @@ internal sealed class DalamudInitializer : IDisposable
         QuestWindow questWindow,
         DebugOverlay debugOverlay,
         ConfigWindow configWindow,
-        QuestSelectionWindow questSelectionWindow)
+        QuestSelectionWindow questSelectionWindow,
+        QuestValidationWindow questValidationWindow)
     {
         _pluginInterface = pluginInterface;
         _framework = framework;
@@ -44,6 +45,7 @@ internal sealed class DalamudInitializer : IDisposable
         _windowSystem.AddWindow(configWindow);
         _windowSystem.AddWindow(debugOverlay);
         _windowSystem.AddWindow(questSelectionWindow);
+        _windowSystem.AddWindow(questValidationWindow);
 
         _pluginInterface.UiBuilder.Draw += _windowSystem.Draw;
         _pluginInterface.UiBuilder.OpenMainUi += _questWindow.Toggle;
index f25cec483d3a67670d8e9b895337bdd917aca5d1..7bca424145f0a2338ba8da3827c192af6a182d3f 100644 (file)
@@ -1,6 +1,7 @@
 using System;
 using System.Collections.Generic;
 using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
 using System.Linq;
 using System.Numerics;
 using Dalamud.Game.ClientState.Conditions;
@@ -260,6 +261,7 @@ internal sealed unsafe class GameFunctions
         return questManager->IsQuestAccepted(questId);
     }
 
+    [SuppressMessage("Performance", "CA1822")]
     public bool IsQuestComplete(ushort questId)
     {
         return QuestManager.IsQuestComplete(questId);
index 9647fc3ba52f6be25d4c9605db3853df9418df6e..39c370d0465cf07a93244effe7ebf46159f01a21 100644 (file)
@@ -16,6 +16,8 @@ using Questionable.Controller.Steps.Common;
 using Questionable.Controller.Steps.Interactions;
 using Questionable.Data;
 using Questionable.External;
+using Questionable.Validation;
+using Questionable.Validation.Validators;
 using Questionable.Windows;
 using Action = Questionable.Controller.Steps.Interactions.Action;
 
@@ -128,6 +130,13 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddSingleton<ConfigWindow>();
         serviceCollection.AddSingleton<DebugOverlay>();
         serviceCollection.AddSingleton<QuestSelectionWindow>();
+        serviceCollection.AddSingleton<QuestValidationWindow>();
+
+        serviceCollection.AddSingleton<QuestValidator>();
+        serviceCollection.AddSingleton<IQuestValidator, QuestDisabledValidator>();
+        serviceCollection.AddSingleton<IQuestValidator, BasicSequenceValidator>();
+        serviceCollection.AddSingleton<IQuestValidator, UniqueStartStopValidator>();
+        serviceCollection.AddSingleton<IQuestValidator, CompletionFlagsValidator>();
 
         serviceCollection.AddSingleton<CommandHandler>();
         serviceCollection.AddSingleton<DalamudInitializer>();
diff --git a/Questionable/Validation/EIssueSeverity.cs b/Questionable/Validation/EIssueSeverity.cs
new file mode 100644 (file)
index 0000000..25f4253
--- /dev/null
@@ -0,0 +1,7 @@
+namespace Questionable.Validation;
+
+internal enum EIssueSeverity
+{
+    None,
+    Error,
+}
diff --git a/Questionable/Validation/IQuestValidator.cs b/Questionable/Validation/IQuestValidator.cs
new file mode 100644 (file)
index 0000000..02a5f18
--- /dev/null
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+using Questionable.Model;
+
+namespace Questionable.Validation;
+
+internal interface IQuestValidator
+{
+    IEnumerable<ValidationIssue> Validate(Quest quest);
+}
diff --git a/Questionable/Validation/QuestValidator.cs b/Questionable/Validation/QuestValidator.cs
new file mode 100644 (file)
index 0000000..83bb9d4
--- /dev/null
@@ -0,0 +1,55 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Questionable.Model;
+
+namespace Questionable.Validation;
+
+internal sealed class QuestValidator
+{
+    private readonly IReadOnlyList<IQuestValidator> _validators;
+    private readonly ILogger<QuestValidator> _logger;
+
+    private List<ValidationIssue> _validationIssues = new();
+
+    public QuestValidator(IEnumerable<IQuestValidator> validators, ILogger<QuestValidator> logger)
+    {
+        _validators = validators.ToList();
+        _logger = logger;
+
+        _logger.LogInformation("Validators: {Validators}",
+            string.Join(", ", _validators.Select(x => x.GetType().Name)));
+    }
+
+    public IReadOnlyList<ValidationIssue> Issues => _validationIssues;
+    public int IssueCount => _validationIssues.Count;
+
+    public void ClearIssues() => _validationIssues.Clear();
+
+    public void Validate(IReadOnlyCollection<Quest> quests)
+    {
+        Task.Run(() =>
+        {
+            foreach (var quest in quests)
+            {
+                foreach (var validator in _validators)
+                {
+                    foreach (var issue in validator.Validate(quest))
+                    {
+                        _logger.LogWarning(
+                            "Validation failed: {QuestId} ({QuestName}) / {QuestSequence} / {QuestStep} - {Description}",
+                            issue.QuestId, quest.Info.Name, issue.Sequence, issue.Step, issue.Description);
+                        _validationIssues.Add(issue);
+                    }
+                }
+            }
+
+            _validationIssues = _validationIssues.OrderBy(x => x.QuestId)
+                .ThenBy(x => x.Sequence)
+                .ThenBy(x => x.Step)
+                .ThenBy(x => x.Description)
+                .ToList();
+        });
+    }
+}
diff --git a/Questionable/Validation/ValidationIssue.cs b/Questionable/Validation/ValidationIssue.cs
new file mode 100644 (file)
index 0000000..6e84276
--- /dev/null
@@ -0,0 +1,10 @@
+namespace Questionable.Validation;
+
+internal sealed record ValidationIssue
+{
+    public required ushort QuestId { get; init; }
+    public required byte? Sequence { get; init; }
+    public required int? Step { get; init; }
+    public required EIssueSeverity Severity { get; init; }
+    public required string Description { get; init; }
+}
diff --git a/Questionable/Validation/Validators/BasicSequenceValidator.cs b/Questionable/Validation/Validators/BasicSequenceValidator.cs
new file mode 100644 (file)
index 0000000..8ea19a1
--- /dev/null
@@ -0,0 +1,79 @@
+using System.Collections.Generic;
+using System.Linq;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Validation.Validators;
+
+internal sealed class BasicSequenceValidator : IQuestValidator
+{
+    /// <summary>
+    /// A quest should have sequences from 0 to N, and (if more than 'AcceptQuest' exists), a 255 sequence.
+    /// </summary>
+    public IEnumerable<ValidationIssue> Validate(Quest quest)
+    {
+        var sequences = quest.Root.QuestSequence;
+        var foundStart = sequences.FirstOrDefault(x => x.Sequence == 0);
+        if (foundStart == null)
+        {
+            yield return new ValidationIssue
+            {
+                QuestId = quest.QuestId,
+                Sequence = 0,
+                Step = null,
+                Severity = EIssueSeverity.Error,
+                Description = "Missing quest start",
+            };
+            yield break;
+        }
+
+        int maxSequence = sequences.Select(x => x.Sequence)
+            .Where(x => x != 255)
+            .Max();
+
+        for (int i = 0; i < maxSequence; i++)
+        {
+            var foundSequences = sequences.Where(x => x.Sequence == i).ToList();
+            var issue = ValidateSequences(quest, i, foundSequences);
+            if (issue != null)
+                yield return issue;
+        }
+
+        // some quests finish instantly
+        if (maxSequence > 0 || foundStart.Steps.Count > 1)
+        {
+            var foundEnding = sequences.Where(x => x.Sequence == 255).ToList();
+            var endingIssue = ValidateSequences(quest, 255, foundEnding);
+            if (endingIssue != null)
+                yield return endingIssue;
+        }
+    }
+
+    private static ValidationIssue? ValidateSequences(Quest quest, int sequenceNo, List<QuestSequence> foundSequences)
+    {
+        if (foundSequences.Count == 0)
+        {
+            return new ValidationIssue
+            {
+                QuestId = quest.QuestId,
+                Sequence = (byte)sequenceNo,
+                Step = null,
+                Severity = EIssueSeverity.Error,
+                Description = "Missing sequence",
+            };
+        }
+        else if (foundSequences.Count == 2)
+        {
+            return new ValidationIssue
+            {
+                QuestId = quest.QuestId,
+                Sequence = (byte)sequenceNo,
+                Step = null,
+                Severity = EIssueSeverity.Error,
+                Description = "Duplicate sequence",
+            };
+        }
+        else
+            return null;
+    }
+}
diff --git a/Questionable/Validation/Validators/CompletionFlagsValidator.cs b/Questionable/Validation/Validators/CompletionFlagsValidator.cs
new file mode 100644 (file)
index 0000000..f1ea4c0
--- /dev/null
@@ -0,0 +1,54 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using Questionable.Controller.Utils;
+using Questionable.Model;
+
+namespace Questionable.Validation.Validators;
+
+internal sealed class CompletionFlagsValidator : IQuestValidator
+{
+    public IEnumerable<ValidationIssue> Validate(Quest quest)
+    {
+        foreach (var sequence in quest.Root.QuestSequence)
+        {
+            var mappedCompletionFlags = sequence.Steps
+                .Select(x =>
+                {
+                    if (QuestWorkUtils.HasCompletionFlags(x.CompletionQuestVariablesFlags))
+                    {
+                        return Enumerable.Range(0, 6).Select(y =>
+                            {
+                                short? value = x.CompletionQuestVariablesFlags[y];
+                                if (value == null || value.Value < 0)
+                                    return 0;
+                                return (long)BitOperations.RotateLeft((ulong)value.Value, 8 * y);
+                            })
+                            .Sum();
+                    }
+                    else
+                        return 0;
+                })
+                .ToList();
+
+            for (int i = 0; i < sequence.Steps.Count; ++i)
+            {
+                var flags = mappedCompletionFlags[i];
+                if (flags == 0)
+                    continue;
+
+                if (mappedCompletionFlags.Count(x => x == flags) >= 2)
+                {
+                    yield return new ValidationIssue
+                    {
+                        QuestId = quest.QuestId,
+                        Sequence = (byte)sequence.Sequence,
+                        Step = i,
+                        Severity = EIssueSeverity.Error,
+                        Description = $"Duplicate completion flags: {string.Join(", ", sequence.Steps[i].CompletionQuestVariablesFlags)}",
+                    };
+                }
+            }
+        }
+    }
+}
diff --git a/Questionable/Validation/Validators/QuestDisabledValidator.cs b/Questionable/Validation/Validators/QuestDisabledValidator.cs
new file mode 100644 (file)
index 0000000..096fd37
--- /dev/null
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+using Questionable.Model;
+
+namespace Questionable.Validation.Validators;
+
+internal sealed class QuestDisabledValidator : IQuestValidator
+{
+    public IEnumerable<ValidationIssue> Validate(Quest quest)
+    {
+        if (quest.Root.Disabled)
+        {
+            yield return new ValidationIssue
+            {
+                QuestId = quest.QuestId,
+                Sequence = null,
+                Step = null,
+                Severity = EIssueSeverity.None,
+                Description = "Quest is disabled",
+            };
+        }
+    }
+}
diff --git a/Questionable/Validation/Validators/UniqueStartStopValidator.cs b/Questionable/Validation/Validators/UniqueStartStopValidator.cs
new file mode 100644 (file)
index 0000000..83c350a
--- /dev/null
@@ -0,0 +1,85 @@
+using System.Collections.Generic;
+using System.Linq;
+using Questionable.Model;
+using Questionable.Model.V1;
+
+namespace Questionable.Validation.Validators;
+
+internal sealed class UniqueStartStopValidator : IQuestValidator
+{
+    public IEnumerable<ValidationIssue> Validate(Quest quest)
+    {
+        var questAccepts = FindQuestStepsWithInteractionType(quest, EInteractionType.AcceptQuest)
+            .Where(x => x.Step.PickupQuestId == null)
+            .ToList();
+        foreach (var accept in questAccepts)
+        {
+            if (accept.SequenceId != 0 || accept.StepId != quest.FindSequence(0)!.Steps.Count - 1)
+            {
+                yield return new ValidationIssue
+                {
+                    QuestId = quest.QuestId,
+                    Sequence = (byte)accept.SequenceId,
+                    Step = accept.StepId,
+                    Severity = EIssueSeverity.Error,
+                    Description = "Unexpected AcceptQuest step",
+                };
+            }
+        }
+
+        if (quest.FindSequence(0) != null && questAccepts.Count == 0)
+        {
+            yield return new ValidationIssue
+            {
+                QuestId = quest.QuestId,
+                Sequence = 0,
+                Step = null,
+                Severity = EIssueSeverity.Error,
+                Description = "No AcceptQuest step",
+            };
+        }
+
+        var questCompletes = FindQuestStepsWithInteractionType(quest, EInteractionType.CompleteQuest)
+            .Where(x => x.Step.TurnInQuestId == null)
+            .ToList();
+        foreach (var complete in questCompletes)
+        {
+            if (complete.SequenceId != 255 || complete.StepId != quest.FindSequence(255)!.Steps.Count - 1)
+            {
+                yield return new ValidationIssue
+                {
+                    QuestId = quest.QuestId,
+                    Sequence = (byte)complete.SequenceId,
+                    Step = complete.StepId,
+                    Severity = EIssueSeverity.Error,
+                    Description = "Unexpected CompleteQuest step",
+                };
+            }
+        }
+
+        if (quest.FindSequence(255) != null && questCompletes.Count == 0)
+        {
+            yield return new ValidationIssue
+            {
+                QuestId = quest.QuestId,
+                Sequence = 255,
+                Step = null,
+                Severity = EIssueSeverity.Error,
+                Description = "No CompleteQuest step",
+            };
+        }
+    }
+
+    private static IEnumerable<(int SequenceId, int StepId, QuestStep Step)> FindQuestStepsWithInteractionType(Quest quest, EInteractionType interactionType)
+    {
+        foreach (var sequence in quest.Root.QuestSequence)
+        {
+            for (int i = 0; i < sequence.Steps.Count; ++i)
+            {
+                var step = sequence.Steps[i];
+                if (step.InteractionType == interactionType)
+                    yield return (sequence.Sequence, i, step);
+            }
+        }
+    }
+}
index 8e0bd9714506fca27c330ba30af1a4a15aa8da9f..55d81bb50d03b49dd471cacc6ed05a0728ac6b86 100644 (file)
@@ -105,7 +105,7 @@ internal sealed class QuestSelectionWindow : LWindow
     public override void OnClose()
     {
         TargetId = default;
-        TargetName = default;
+        TargetName = string.Empty;
         _quests = [];
         _offeredQuests = [];
     }
diff --git a/Questionable/Windows/QuestValidationWindow.cs b/Questionable/Windows/QuestValidationWindow.cs
new file mode 100644 (file)
index 0000000..2cb3f5b
--- /dev/null
@@ -0,0 +1,69 @@
+using System.Globalization;
+using Dalamud.Interface;
+using Dalamud.Interface.Colors;
+using Dalamud.Interface.Components;
+using Dalamud.Interface.Utility.Raii;
+using FFXIVClientStructs.FFXIV.Common.Math;
+using ImGuiNET;
+using LLib.ImGui;
+using Questionable.Data;
+using Questionable.Model;
+using Questionable.Validation;
+
+namespace Questionable.Windows;
+
+internal sealed class QuestValidationWindow : LWindow
+{
+    private readonly QuestValidator _questValidator;
+    private readonly QuestData _questData;
+
+    public QuestValidationWindow(QuestValidator questValidator, QuestData questData) : base("Quest Validation###QuestionableValidator")
+    {
+        _questValidator = questValidator;
+        _questData = questData;
+
+        Size = new Vector2(600, 200);
+        SizeCondition = ImGuiCond.Once;
+        SizeConstraints = new WindowSizeConstraints
+        {
+            MinimumSize = new Vector2(600, 200),
+        };
+    }
+
+    public override void Draw()
+    {
+        using var table = ImRaii.Table("QuestSelection", 5, ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY);
+        if (!table)
+        {
+            ImGui.Text("Not table");
+            return;
+        }
+
+        ImGui.TableSetupColumn("Quest", ImGuiTableColumnFlags.WidthFixed, 50);
+        ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, 200);
+        ImGui.TableSetupColumn("Sq", ImGuiTableColumnFlags.WidthFixed, 30);
+        ImGui.TableSetupColumn("Sp", ImGuiTableColumnFlags.WidthFixed, 30);
+        ImGui.TableSetupColumn("Issue", ImGuiTableColumnFlags.None, 200);
+        ImGui.TableHeadersRow();
+
+        foreach (ValidationIssue validationIssue in _questValidator.Issues)
+        {
+            ImGui.TableNextRow();
+
+            if (ImGui.TableNextColumn())
+                ImGui.TextUnformatted(validationIssue.QuestId.ToString(CultureInfo.InvariantCulture));
+
+            if (ImGui.TableNextColumn())
+                ImGui.TextUnformatted(_questData.GetQuestInfo(validationIssue.QuestId).Name);
+
+            if (ImGui.TableNextColumn())
+                ImGui.TextUnformatted(validationIssue.Sequence?.ToString(CultureInfo.InvariantCulture) ?? string.Empty);
+
+            if (ImGui.TableNextColumn())
+                ImGui.TextUnformatted(validationIssue.Step?.ToString(CultureInfo.InvariantCulture) ?? string.Empty);
+
+            if (ImGui.TableNextColumn())
+                ImGui.TextUnformatted(validationIssue.Description);
+        }
+    }
+}
index 69793cc29d56c06e067f079ee358378737461686..517d88940a43d25a14b755235547c8fe19af0c61 100644 (file)
@@ -48,6 +48,7 @@ internal sealed class QuestWindow : LWindow, IPersistableWindowConfig
     private readonly ICondition _condition;
     private readonly IGameGui _gameGui;
     private readonly QuestSelectionWindow _questSelectionWindow;
+    private readonly QuestValidationWindow _questValidationWindow;
     private readonly ILogger<QuestWindow> _logger;
 
     public QuestWindow(IDalamudPluginInterface pluginInterface,
@@ -68,6 +69,7 @@ internal sealed class QuestWindow : LWindow, IPersistableWindowConfig
         ICondition condition,
         IGameGui gameGui,
         QuestSelectionWindow questSelectionWindow,
+        QuestValidationWindow questValidationWindow,
         ILogger<QuestWindow> logger)
         : base("Questionable###Questionable", ImGuiWindowFlags.AlwaysAutoResize)
     {
@@ -89,6 +91,7 @@ internal sealed class QuestWindow : LWindow, IPersistableWindowConfig
         _condition = condition;
         _gameGui = gameGui;
         _questSelectionWindow = questSelectionWindow;
+        _questValidationWindow = questValidationWindow;
         _logger = logger;
 
 #if DEBUG
@@ -414,7 +417,8 @@ internal sealed class QuestWindow : LWindow, IPersistableWindowConfig
                 $"Target: {_targetManager.Target.Name}  ({_targetManager.Target.ObjectKind}; {_targetManager.Target.DataId})"));
 
             GameObject* gameObject = (GameObject*)_targetManager.Target.Address;
-            ImGui.Text(string.Create(CultureInfo.InvariantCulture, $"Distance: {(_targetManager.Target.Position - _clientState.LocalPlayer.Position).Length():F2}"));
+            ImGui.Text(string.Create(CultureInfo.InvariantCulture,
+                $"Distance: {(_targetManager.Target.Position - _clientState.LocalPlayer.Position).Length():F2}"));
             ImGui.SameLine();
 
             float verticalDistance = _targetManager.Target.Position.Y - _clientState.LocalPlayer.Position.Y;
@@ -471,7 +475,8 @@ internal sealed class QuestWindow : LWindow, IPersistableWindowConfig
 
             bool copy = ImGuiComponents.IconButton(FontAwesomeIcon.Copy);
             if (ImGui.IsItemHovered())
-                ImGui.SetTooltip("Left click: Copy target position as JSON.\nRight click: Copy target position as C# code.");
+                ImGui.SetTooltip(
+                    "Left click: Copy target position as JSON.\nRight click: Copy target position as C# code.");
             if (copy)
             {
                 string interactionType = gameObject->NamePlateIconId switch
@@ -509,7 +514,8 @@ internal sealed class QuestWindow : LWindow, IPersistableWindowConfig
         {
             bool copy = ImGuiComponents.IconButton(FontAwesomeIcon.Copy);
             if (ImGui.IsItemHovered())
-                ImGui.SetTooltip("Left click: Copy your position as JSON.\nRight click: Copy your position as C# code.");
+                ImGui.SetTooltip(
+                    "Left click: Copy your position as JSON.\nRight click: Copy your position as C# code.");
             if (copy)
             {
                 ImGui.SetClipboardText($$"""
@@ -570,6 +576,18 @@ internal sealed class QuestWindow : LWindow, IPersistableWindowConfig
             _framework.RunOnTick(() => _gameUiController.HandleCurrentDialogueChoices(),
                 TimeSpan.FromMilliseconds(200));
         }
+
+#if DEBUG
+        if (_questRegistry.ValidationIssueCount > 0)
+        {
+            ImGui.SameLine();
+
+            using var textColor = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed);
+            if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.ExclamationTriangle,
+                    $"{_questRegistry.ValidationIssueCount}"))
+                _questValidationWindow.IsOpen = true;
+        }
+#endif
     }
 
     private void DrawRemainingTasks()