302 lines
10 KiB
C#
302 lines
10 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.Json.Serialization;
|
|
using System.Text.Json.Serialization.Metadata;
|
|
using Json.Schema;
|
|
using Questionable.Model;
|
|
using Questionable.Model.Questing;
|
|
|
|
namespace Questionable.Validation.Validators;
|
|
|
|
internal sealed class JsonSchemaValidator : IQuestValidator
|
|
{
|
|
private readonly SchemaRegistrar _schemaRegistrar;
|
|
|
|
private readonly Dictionary<ElementId, JsonNode> _questNodes = new Dictionary<ElementId, JsonNode>();
|
|
|
|
private JsonSchema? _questSchema;
|
|
|
|
private const string QuestSchemaUri = "https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/QuestPaths/quest-v1.json";
|
|
|
|
private static readonly JsonSerializerOptions ValidationSerializationOptions = new JsonSerializerOptions
|
|
{
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
|
|
IncludeFields = false,
|
|
WriteIndented = false,
|
|
TypeInfoResolver = new DefaultJsonTypeInfoResolver
|
|
{
|
|
Modifiers = { (Action<JsonTypeInfo>)NoEmptyCollectionModifier }
|
|
}
|
|
};
|
|
|
|
public JsonSchemaValidator(SchemaRegistrar schemaRegistrar)
|
|
{
|
|
_schemaRegistrar = schemaRegistrar;
|
|
}
|
|
|
|
public IEnumerable<ValidationIssue> Validate(Quest quest)
|
|
{
|
|
if (!_questNodes.TryGetValue(quest.Id, out JsonNode questNode))
|
|
{
|
|
yield break;
|
|
}
|
|
if (_questSchema == null)
|
|
{
|
|
_questSchema = _schemaRegistrar.Get("https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/QuestPaths/quest-v1.json");
|
|
}
|
|
EvaluationResults evaluationResults = _questSchema.Evaluate(JsonSerializer.SerializeToElement(questNode), new EvaluationOptions
|
|
{
|
|
Culture = CultureInfo.InvariantCulture,
|
|
OutputFormat = OutputFormat.Hierarchical
|
|
});
|
|
if (evaluationResults.IsValid)
|
|
{
|
|
yield break;
|
|
}
|
|
Dictionary<int, byte> sequenceMap = BuildSequenceMap(questNode);
|
|
List<SchemaValidationHelper.ParsedError> list = new List<SchemaValidationHelper.ParsedError>();
|
|
SchemaValidationHelper.CollectLeafErrors(evaluationResults, list);
|
|
if (list.Count == 0)
|
|
{
|
|
yield return new ValidationIssue
|
|
{
|
|
PathType = EPathType.Quest,
|
|
Id = quest.Id.ToString(),
|
|
Location = null,
|
|
Type = EIssueType.InvalidJsonSchema,
|
|
Severity = EIssueSeverity.Error,
|
|
Description = "JSON schema validation failed"
|
|
};
|
|
yield break;
|
|
}
|
|
List<(byte?, int?, string, string)> source = list.Select(delegate(SchemaValidationHelper.ParsedError e)
|
|
{
|
|
var (item, item2, rawPath) = ParseInstanceLocation(e.InstancePath, sequenceMap);
|
|
return (Sequence: item, Step: item2, PropertyPath: SchemaValidationHelper.FormatPath(rawPath), Message: e.Message);
|
|
}).ToList();
|
|
foreach (IGrouping<(byte?, int?), (byte?, int?, string, string)> item3 in from e in source
|
|
group e by (Sequence: e.Sequence, Step: e.Step))
|
|
{
|
|
List<string> list2 = item3.Select<(byte?, int?, string, string), string>(((byte? Sequence, int? Step, string PropertyPath, string Message) e) => (!string.IsNullOrEmpty(e.PropertyPath)) ? (e.PropertyPath + ": " + e.Message) : e.Message).Distinct().ToList();
|
|
SimplifyConditionalRequiredErrors(list2, FindStepNode(questNode, item3.Key.Item1, item3.Key.Item2));
|
|
if (list2.Count != 0)
|
|
{
|
|
yield return new ValidationIssue
|
|
{
|
|
PathType = EPathType.Quest,
|
|
Id = quest.Id.ToString(),
|
|
Location = SchemaValidationHelper.FormatQuestLocation(item3.Key.Item1, item3.Key.Item2),
|
|
Type = EIssueType.InvalidJsonSchema,
|
|
Severity = EIssueSeverity.Error,
|
|
Description = string.Join("\n", list2)
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
public List<ValidationIssue> ValidateImmediate(ElementId elementId, JsonNode questNode)
|
|
{
|
|
if (_questSchema == null)
|
|
{
|
|
_questSchema = _schemaRegistrar.Get("https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/QuestPaths/quest-v1.json");
|
|
}
|
|
EvaluationResults evaluationResults = _questSchema.Evaluate(JsonSerializer.SerializeToElement(questNode), new EvaluationOptions
|
|
{
|
|
Culture = CultureInfo.InvariantCulture,
|
|
OutputFormat = OutputFormat.Hierarchical
|
|
});
|
|
if (evaluationResults.IsValid)
|
|
{
|
|
return new List<ValidationIssue>();
|
|
}
|
|
Dictionary<int, byte> sequenceMap = BuildSequenceMap(questNode);
|
|
List<SchemaValidationHelper.ParsedError> list = new List<SchemaValidationHelper.ParsedError>();
|
|
SchemaValidationHelper.CollectLeafErrors(evaluationResults, list);
|
|
if (list.Count == 0)
|
|
{
|
|
int num = 1;
|
|
List<ValidationIssue> list2 = new List<ValidationIssue>(num);
|
|
CollectionsMarshal.SetCount(list2, num);
|
|
CollectionsMarshal.AsSpan(list2)[0] = new ValidationIssue
|
|
{
|
|
PathType = EPathType.Quest,
|
|
Id = elementId.ToString(),
|
|
Location = null,
|
|
Type = EIssueType.InvalidJsonSchema,
|
|
Severity = EIssueSeverity.Error,
|
|
Description = "JSON schema validation failed"
|
|
};
|
|
return list2;
|
|
}
|
|
List<ValidationIssue> list3 = new List<ValidationIssue>();
|
|
foreach (IGrouping<(byte?, int?), (byte?, int?, string, string)> item3 in from e in list.Select(delegate(SchemaValidationHelper.ParsedError e)
|
|
{
|
|
var (item, item2, rawPath) = ParseInstanceLocation(e.InstancePath, sequenceMap);
|
|
return (Sequence: item, Step: item2, PropertyPath: SchemaValidationHelper.FormatPath(rawPath), Message: e.Message);
|
|
}).ToList()
|
|
group e by (Sequence: e.Sequence, Step: e.Step))
|
|
{
|
|
List<string> list4 = item3.Select<(byte?, int?, string, string), string>(((byte? Sequence, int? Step, string PropertyPath, string Message) e) => (!string.IsNullOrEmpty(e.PropertyPath)) ? (e.PropertyPath + ": " + e.Message) : e.Message).Distinct().ToList();
|
|
if (list4.Count != 0)
|
|
{
|
|
list3.Add(new ValidationIssue
|
|
{
|
|
PathType = EPathType.Quest,
|
|
Id = elementId.ToString(),
|
|
Location = SchemaValidationHelper.FormatQuestLocation(item3.Key.Item1, item3.Key.Item2),
|
|
Type = EIssueType.InvalidJsonSchema,
|
|
Severity = EIssueSeverity.Error,
|
|
Description = string.Join("\n", list4)
|
|
});
|
|
}
|
|
}
|
|
return list3;
|
|
}
|
|
|
|
private static void SimplifyConditionalRequiredErrors(List<string> lines, JsonObject? step)
|
|
{
|
|
if (step == null)
|
|
{
|
|
return;
|
|
}
|
|
foreach (string property in from x in step
|
|
where x.Value != null
|
|
select x.Key)
|
|
{
|
|
lines.RemoveAll((string x) => IsRequiredPropertyError(x, property));
|
|
}
|
|
if (step["InteractionType"]?.GetValue<string>() != "Combat")
|
|
{
|
|
lines.RemoveAll((string x) => IsRequiredPropertyError(x, "EnemySpawnType") || IsRequiredPropertyError(x, "KillEnemyDataIds") || IsRequiredPropertyError(x, "ComplexCombatData"));
|
|
return;
|
|
}
|
|
string obj = step["EnemySpawnType"]?.GetValue<string>();
|
|
if (obj != "AfterItemUse")
|
|
{
|
|
lines.RemoveAll((string x) => IsRequiredPropertyError(x, "ItemId"));
|
|
}
|
|
if (obj == "FinishCombatIfAny")
|
|
{
|
|
lines.RemoveAll((string x) => IsRequiredPropertyError(x, "KillEnemyDataIds") || IsRequiredPropertyError(x, "ComplexCombatData"));
|
|
return;
|
|
}
|
|
bool num = lines.RemoveAll((string x) => IsRequiredPropertyError(x, "KillEnemyDataIds")) > 0;
|
|
bool flag = lines.RemoveAll((string x) => IsRequiredPropertyError(x, "ComplexCombatData")) > 0;
|
|
if (num && flag)
|
|
{
|
|
lines.Add("One combat target definition is required: KillEnemyDataIds or ComplexCombatData");
|
|
}
|
|
}
|
|
|
|
private static bool IsRequiredPropertyError(string message, string property)
|
|
{
|
|
return message.Contains("Required properties [\"" + property + "\"]", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static JsonObject? FindStepNode(JsonNode questNode, byte? sequence, int? step)
|
|
{
|
|
if (!sequence.HasValue || !step.HasValue || !(questNode["QuestSequence"] is JsonArray source))
|
|
{
|
|
return null;
|
|
}
|
|
if (!(source.OfType<JsonObject>().FirstOrDefault((JsonObject x) => x["Sequence"]?.GetValue<int>() == sequence.Value)?["Steps"] is JsonArray jsonArray) || step.Value < 0 || step.Value >= jsonArray.Count)
|
|
{
|
|
return null;
|
|
}
|
|
return jsonArray[step.Value] as JsonObject;
|
|
}
|
|
|
|
public void Enqueue(ElementId elementId, JsonNode questNode)
|
|
{
|
|
_questNodes[elementId] = questNode;
|
|
}
|
|
|
|
public void Reset()
|
|
{
|
|
_questNodes.Clear();
|
|
}
|
|
|
|
private static Dictionary<int, byte> BuildSequenceMap(JsonNode questNode)
|
|
{
|
|
Dictionary<int, byte> dictionary = new Dictionary<int, byte>();
|
|
if (!(questNode["QuestSequence"] is JsonArray jsonArray))
|
|
{
|
|
return dictionary;
|
|
}
|
|
for (int i = 0; i < jsonArray.Count; i++)
|
|
{
|
|
JsonNode jsonNode = jsonArray[i]?["Sequence"];
|
|
if (jsonNode != null && jsonNode.GetValueKind() == JsonValueKind.Number)
|
|
{
|
|
dictionary[i] = (byte)jsonNode.GetValue<int>();
|
|
}
|
|
}
|
|
return dictionary;
|
|
}
|
|
|
|
private static (byte? Sequence, int? Step, string Path) ParseInstanceLocation(string instancePath, Dictionary<int, byte> sequenceMap)
|
|
{
|
|
if (string.IsNullOrEmpty(instancePath))
|
|
{
|
|
return (Sequence: null, Step: null, Path: string.Empty);
|
|
}
|
|
string[] array = instancePath.Split('/');
|
|
byte? item = null;
|
|
int? item2 = null;
|
|
int num = 1;
|
|
for (int i = 1; i < array.Length - 1; i++)
|
|
{
|
|
if (!(array[i] != "QuestSequence") && int.TryParse(array[i + 1], out var result))
|
|
{
|
|
item = (sequenceMap.TryGetValue(result, out var value) ? new byte?(value) : ((byte?)null));
|
|
num = i + 2;
|
|
if (i + 3 < array.Length && array[i + 2] == "Steps" && int.TryParse(array[i + 3], out var result2))
|
|
{
|
|
item2 = result2;
|
|
num = i + 4;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
string item3 = ((num < array.Length) ? string.Join("/", array, num, array.Length - num) : string.Empty);
|
|
return (Sequence: item, Step: item2, Path: item3);
|
|
}
|
|
|
|
private static void NoEmptyCollectionModifier(JsonTypeInfo typeInfo)
|
|
{
|
|
foreach (JsonPropertyInfo property in typeInfo.Properties)
|
|
{
|
|
if (typeof(ICollection).IsAssignableFrom(property.PropertyType))
|
|
{
|
|
property.ShouldSerialize = (object _, object? val) => val is ICollection collection && collection.Count > 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
public IEnumerable<ValidationIssue> ValidateFromQuestRoot(Quest quest)
|
|
{
|
|
return ValidateFromQuestRoot(quest.Id, quest.Root);
|
|
}
|
|
|
|
public IEnumerable<ValidationIssue> ValidateFromQuestRoot(ElementId elementId, QuestRoot questRoot)
|
|
{
|
|
if (!(JsonSerializer.SerializeToNode(questRoot, ValidationSerializationOptions) is JsonObject jsonObject))
|
|
{
|
|
return Array.Empty<ValidationIssue>();
|
|
}
|
|
JsonObject jsonObject2 = new JsonObject { ["$schema"] = "https://github.com/WigglyMuffin/Questionable/raw/refs/heads/main/QuestPaths/quest-v1.json" };
|
|
foreach (KeyValuePair<string, JsonNode> item in jsonObject)
|
|
{
|
|
item.Deconstruct(out var key, out var value);
|
|
string propertyName = key;
|
|
jsonObject2[propertyName] = value?.DeepClone();
|
|
}
|
|
return ValidateImmediate(elementId, jsonObject2);
|
|
}
|
|
}
|