qstbak/Questionable/Questionable.Validation.Validators/JsonSchemaValidator.cs
2026-08-17 20:29:32 +10:00

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);
}
}