using Questionable.Data;
using Questionable.Model;
using Questionable.Model.V1;
+using Questionable.Validation;
namespace Questionable.Controller;
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()
{
[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);
}
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;
}
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;
}
QuestWindow questWindow,
DebugOverlay debugOverlay,
ConfigWindow configWindow,
- QuestSelectionWindow questSelectionWindow)
+ QuestSelectionWindow questSelectionWindow,
+ QuestValidationWindow questValidationWindow)
{
_pluginInterface = pluginInterface;
_framework = framework;
_windowSystem.AddWindow(configWindow);
_windowSystem.AddWindow(debugOverlay);
_windowSystem.AddWindow(questSelectionWindow);
+ _windowSystem.AddWindow(questValidationWindow);
_pluginInterface.UiBuilder.Draw += _windowSystem.Draw;
_pluginInterface.UiBuilder.OpenMainUi += _questWindow.Toggle;
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;
return questManager->IsQuestAccepted(questId);
}
+ [SuppressMessage("Performance", "CA1822")]
public bool IsQuestComplete(ushort questId)
{
return QuestManager.IsQuestComplete(questId);
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;
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>();
--- /dev/null
+namespace Questionable.Validation;
+
+internal enum EIssueSeverity
+{
+ None,
+ Error,
+}
--- /dev/null
+using System.Collections.Generic;
+using Questionable.Model;
+
+namespace Questionable.Validation;
+
+internal interface IQuestValidator
+{
+ IEnumerable<ValidationIssue> Validate(Quest quest);
+}
--- /dev/null
+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();
+ });
+ }
+}
--- /dev/null
+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; }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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)}",
+ };
+ }
+ }
+ }
+ }
+}
--- /dev/null
+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",
+ };
+ }
+ }
+}
--- /dev/null
+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);
+ }
+ }
+ }
+}
public override void OnClose()
{
TargetId = default;
- TargetName = default;
+ TargetName = string.Empty;
_quests = [];
_offeredQuests = [];
}
--- /dev/null
+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);
+ }
+ }
+}
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,
ICondition condition,
IGameGui gameGui,
QuestSelectionWindow questSelectionWindow,
+ QuestValidationWindow questValidationWindow,
ILogger<QuestWindow> logger)
: base("Questionable###Questionable", ImGuiWindowFlags.AlwaysAutoResize)
{
_condition = condition;
_gameGui = gameGui;
_questSelectionWindow = questSelectionWindow;
+ _questValidationWindow = questValidationWindow;
_logger = logger;
#if DEBUG
$"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;
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
{
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($$"""
_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()