Schema model: catalog, field types, references, checker and line writer shared by tools and validator
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ce15eccb0f
27 files changed
+1038
No files matched your search
@@ -0,0 +1,31 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// A scenario action (<c>action <id> key=value …</c>) as data: what it does and what
|
||||
/// it takes. The mod that implements the action ships its schema next to the code, but
|
||||
/// anyone may describe an action — the schema tools describe the engine's own and other
|
||||
/// mods' actions this way, like type definitions for a library that has none.
|
||||
/// </summary>
|
||||
public class ActionSchema
|
||||
{
|
||||
/// <summary>The action name after <c>action</c> (<c>stat.add</c>).</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>Locale key of the palette title.</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Locale key of the help text.</summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>Free group name the palette sorts by (<c>stats</c>, <c>battle</c>, <c>items</c>).</summary>
|
||||
public string Category { get; set; }
|
||||
|
||||
/// <summary>Named parameters, in form order.</summary>
|
||||
public List<FieldSchema> Params { get; set; } = new();
|
||||
|
||||
/// <summary>The mod whose schema file won for this id (filled by the catalog).</summary>
|
||||
public string SourceMod { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// A DSL command other than <c>action</c> (<c>check</c>, <c>when</c>, <c>text</c>…): its
|
||||
/// positional arguments, its named ones and whether it jumps with <c>-> :label</c>.
|
||||
/// </summary>
|
||||
public class CommandSchema
|
||||
{
|
||||
/// <summary>The command word.</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>Locale key of the palette title.</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Locale key of the help text.</summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>Palette group.</summary>
|
||||
public string Category { get; set; }
|
||||
|
||||
/// <summary>Positional arguments in order (a quoted text, a stat id, a difficulty…).</summary>
|
||||
public List<FieldSchema> Positional { get; set; } = new();
|
||||
|
||||
/// <summary>Named arguments.</summary>
|
||||
public List<FieldSchema> Params { get; set; } = new();
|
||||
|
||||
/// <summary>Locale key naming the arrow target (<c>-> :label</c>); null when the command takes none.</summary>
|
||||
public string Arrow { get; set; }
|
||||
|
||||
/// <summary>The mod whose schema file won for this id (filled by the catalog).</summary>
|
||||
public string SourceMod { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// One described value: a parameter of a scenario action or command, or a field of an
|
||||
/// entity kind. The description is what turns a key into a form control and a checked
|
||||
/// reference: <see cref="Type"/> picks the control (a number box, an entity picker, a
|
||||
/// formula field…), the rest fills it. Unknown types fall back to a text box, so a
|
||||
/// schema written for a newer tool still opens in an older one.
|
||||
/// </summary>
|
||||
public class FieldSchema
|
||||
{
|
||||
/// <summary>The parameter name (<c>enemies</c>) or the JSON property (<c>Traits</c>).</summary>
|
||||
public string Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>text</c>, <c>locale</c> (a locale key with a text per language), <c>int</c>,
|
||||
/// <c>number</c>, <c>bool</c>, <c>choice</c> (<see cref="Options"/>), <c>ref</c> and
|
||||
/// <c>ref_list</c> (entities of <see cref="Kind"/>), <c>picture</c> (a file of
|
||||
/// <see cref="Group"/>), <c>formula</c>, <c>condition</c>, <c>who</c> (player, party
|
||||
/// or a character), <c>scene</c>, <c>flag</c>, <c>label</c>, <c>list</c> (objects of
|
||||
/// <see cref="Of"/>), <c>map</c> (keys from <see cref="Kind"/> to numbers or formulas),
|
||||
/// <c>json</c>.
|
||||
/// </summary>
|
||||
public string Type { get; set; } = "text";
|
||||
|
||||
/// <summary>Locale key of the label; the key itself is shown when empty.</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Locale key of the tooltip and the palette's help line.</summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>Whether the value must be given (the validator reports a missing one).</summary>
|
||||
public bool Required { get; set; }
|
||||
|
||||
/// <summary>The value a form starts with (and what the game assumes when it is left out).</summary>
|
||||
public string Default { get; set; }
|
||||
|
||||
/// <summary>Allowed values of a <c>choice</c>.</summary>
|
||||
public List<string> Options { get; set; }
|
||||
|
||||
/// <summary>The entity kind of a <c>ref</c>, <c>ref_list</c> or the keys of a <c>map</c>.</summary>
|
||||
public string Kind { get; set; }
|
||||
|
||||
/// <summary>The file group of a <c>picture</c> (<c>items</c>, <c>characters</c>).</summary>
|
||||
public string Group { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How a new <c>locale</c> key is made: <c>{id}</c> is the entity id, <c>{mod}</c> the
|
||||
/// mod id, <c>{key}</c> this field's key (<c>"{mod}/stat/{id}"</c>).
|
||||
/// </summary>
|
||||
public string KeyPattern { get; set; }
|
||||
|
||||
/// <summary>The fields of each object of a <c>list</c>.</summary>
|
||||
public List<FieldSchema> Of { get; set; }
|
||||
|
||||
/// <summary>The mod whose schema file declared this field (filled by the catalog).</summary>
|
||||
public string SourceMod { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Mods;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Entity ids and names per kind of the loaded mods, read from their data files the way
|
||||
/// the entity resolver reads them (top-level <c>"Kind"</c>, <c>"Items"</c> with
|
||||
/// <c>"Id"</c>). The tools need it for any kind a schema names — including kinds whose
|
||||
/// C# type the tools have never heard of — to fill reference pickers and to check that
|
||||
/// a referenced id exists. Built on demand; <see cref="Refresh"/> after a save.
|
||||
/// </summary>
|
||||
public static class GameEntityIndex
|
||||
{
|
||||
private static Dictionary<string, SortedDictionary<string, string>> byKind;
|
||||
|
||||
/// <summary>Ids of <paramref name="kind"/> with their name keys, sorted by id.</summary>
|
||||
public static IReadOnlyDictionary<string, string> Of(string kind)
|
||||
{
|
||||
Ensure();
|
||||
return kind != null && byKind.TryGetValue(kind, out var ids) ? ids : new SortedDictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>Whether an entity exists (the checker's callback).</summary>
|
||||
public static bool Exists(string kind, string id) => id != null && Of(kind).Keys.Any(k => string.Equals(k, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>Every kind found in data files, with its entity count.</summary>
|
||||
public static IReadOnlyDictionary<string, int> Kinds()
|
||||
{
|
||||
Ensure();
|
||||
return byKind.ToDictionary(p => p.Key, p => p.Value.Count, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>The text shown for an entity: its translated name and id, or the id.</summary>
|
||||
public static string Label(string kind, string id)
|
||||
{
|
||||
var key = Of(kind).TryGetValue(id ?? string.Empty, out var name) ? name : null;
|
||||
var text = string.IsNullOrEmpty(key) ? null : TranslationServer.Translate(key).ToString();
|
||||
return string.IsNullOrEmpty(text) || text == key ? id : $"{text} · {id}";
|
||||
}
|
||||
|
||||
/// <summary>Forgets the index (data changed on disk).</summary>
|
||||
public static void Refresh() => byKind = null;
|
||||
|
||||
private static void Ensure()
|
||||
{
|
||||
if (byKind != null)
|
||||
return;
|
||||
byKind = new Dictionary<string, SortedDictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
|
||||
var manager = ModManager.Instance;
|
||||
if (manager?.Mods == null)
|
||||
return;
|
||||
foreach (var mod in manager.Mods.Keys)
|
||||
{
|
||||
foreach (var path in JsonFiles(manager, mod, string.Empty).Where(IsData))
|
||||
Read(manager.ReadFileText(mod, path));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> JsonFiles(ModManager manager, string mod, string directory)
|
||||
{
|
||||
// Entries are mod-relative paths; directories end with a slash.
|
||||
foreach (var entry in manager.ListFiles(mod, directory))
|
||||
{
|
||||
if (entry.EndsWith("/"))
|
||||
{
|
||||
foreach (var nested in JsonFiles(manager, mod, entry.TrimEnd('/')))
|
||||
yield return nested;
|
||||
}
|
||||
else if (entry.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return entry.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsData(string path)
|
||||
=> !path.StartsWith("schemas/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !path.StartsWith("patches/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !path.EndsWith("manifest.json", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static void Read(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("Kind", out var kind) || kind.ValueKind != JsonValueKind.String
|
||||
|| !root.TryGetProperty("Items", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||
return;
|
||||
if (!byKind.TryGetValue(kind.GetString(), out var ids))
|
||||
byKind[kind.GetString()] = ids = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var item in items.EnumerateArray().Where(i => i.ValueKind == JsonValueKind.Object))
|
||||
{
|
||||
if (item.TryGetProperty("Id", out var id) && id.ValueKind == JsonValueKind.String)
|
||||
ids[id.GetString()] = item.TryGetProperty("Name", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() : null;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A broken data file is the validator's business; the index just skips it.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// The editable fields of an entity kind (<c>"Kind": "stat"</c>, <c>"item"</c>…). Several
|
||||
/// mods may describe the same kind: their fields are merged, so the items mod describes
|
||||
/// price and picture, the equipment mod adds <c>Equip</c> and <c>Traits</c>, and the
|
||||
/// data editor window shows one form with all of them — the editor grows with the mods
|
||||
/// instead of being rewritten for each.
|
||||
/// </summary>
|
||||
public class KindSchema
|
||||
{
|
||||
/// <summary>The entity kind.</summary>
|
||||
public string Kind { get; set; }
|
||||
|
||||
/// <summary>Locale key of the kind's name in the data editor window.</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Locale key of the help text.</summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>Folder new entities are written to (<c><Folder>/<mod>.json</c>).</summary>
|
||||
public string Folder { get; set; }
|
||||
|
||||
/// <summary>Id a new entity starts from; <c>{mod}</c> is the mod id (<c>"{mod}/new_stat"</c>).</summary>
|
||||
public string NewId { get; set; }
|
||||
|
||||
/// <summary>Order in the data editor window's kind list.</summary>
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>The fields, in form order (merged across mods).</summary>
|
||||
public List<FieldSchema> Fields { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Every schema of the loaded mods, merged: the source of the data editor window's forms,
|
||||
/// the command palette and the schema validator. Files are the <c>schemas</c> file group
|
||||
/// of all mods, read in mod load order, and merged by the same rule the engine uses for
|
||||
/// content — the later mod wins:
|
||||
/// <list type="bullet">
|
||||
/// <item>an action or a command is replaced whole by a later schema with the same id;</item>
|
||||
/// <item>a kind is merged field by field: a later mod adds its fields and replaces a field
|
||||
/// with the same key; its non-empty name, folder and new-id pattern win.</item>
|
||||
/// </list>
|
||||
/// So a mod describes what it adds to someone else's kind without copying their schema,
|
||||
/// and a mod that disagrees with a description replaces only that one entry.
|
||||
/// </summary>
|
||||
public class SchemaCatalog
|
||||
{
|
||||
private const string Group = "schemas";
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true };
|
||||
|
||||
private static SchemaCatalog current;
|
||||
private static object snapshot;
|
||||
|
||||
/// <summary>Actions by id (case-insensitive).</summary>
|
||||
public Dictionary<string, ActionSchema> Actions { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Commands by word (case-insensitive).</summary>
|
||||
public Dictionary<string, CommandSchema> Commands { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Kinds by kind name (case-insensitive).</summary>
|
||||
public Dictionary<string, KindSchema> Kinds { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Files that did not parse, as "mod:path: message".</summary>
|
||||
public List<string> Errors { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// The catalog of the running game, rebuilt when the file group changes (a reload, a
|
||||
/// tool that cleared the file cache after writing a schema).
|
||||
/// </summary>
|
||||
public static SchemaCatalog Current
|
||||
{
|
||||
get
|
||||
{
|
||||
var files = Files.Collect(Group);
|
||||
if (current != null && ReferenceEquals(files, snapshot))
|
||||
return current;
|
||||
snapshot = files;
|
||||
current = Build(Read(files));
|
||||
foreach (var error in current.Errors)
|
||||
GameLogger.Log("TOOLS", "schemas: " + error, LogLevel.Error);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Merges schema files given as (mod, path, JSON text) in load order.</summary>
|
||||
public static SchemaCatalog Build(IEnumerable<(string ModId, string Path, string Json)> files)
|
||||
{
|
||||
var catalog = new SchemaCatalog();
|
||||
foreach (var (modId, path, json) in files)
|
||||
{
|
||||
SchemaFile file;
|
||||
try
|
||||
{
|
||||
file = JsonSerializer.Deserialize<SchemaFile>(json ?? string.Empty, Options);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
catalog.Errors.Add($"{modId}:{path}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
if (file != null)
|
||||
catalog.Merge(modId, file);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
/// <summary>The fields of <paramref name="kind"/>, or an empty list.</summary>
|
||||
public IReadOnlyList<FieldSchema> FieldsOf(string kind)
|
||||
=> kind != null && Kinds.TryGetValue(kind, out var schema) ? schema.Fields : new List<FieldSchema>();
|
||||
|
||||
/// <summary>Applies one mod's file on top of what is merged so far.</summary>
|
||||
public void Merge(string modId, SchemaFile file)
|
||||
{
|
||||
foreach (var action in file.Actions?.Where(a => !string.IsNullOrWhiteSpace(a?.Id)) ?? Enumerable.Empty<ActionSchema>())
|
||||
{
|
||||
action.SourceMod = modId;
|
||||
Stamp(action.Params, modId);
|
||||
Actions[action.Id] = action;
|
||||
}
|
||||
foreach (var command in file.Commands?.Where(c => !string.IsNullOrWhiteSpace(c?.Id)) ?? Enumerable.Empty<CommandSchema>())
|
||||
{
|
||||
command.SourceMod = modId;
|
||||
Stamp(command.Positional, modId);
|
||||
Stamp(command.Params, modId);
|
||||
Commands[command.Id] = command;
|
||||
}
|
||||
foreach (var kind in file.Kinds?.Where(k => !string.IsNullOrWhiteSpace(k?.Kind)) ?? Enumerable.Empty<KindSchema>())
|
||||
MergeKind(modId, kind);
|
||||
}
|
||||
|
||||
private void MergeKind(string modId, KindSchema incoming)
|
||||
{
|
||||
Stamp(incoming.Fields, modId);
|
||||
if (!Kinds.TryGetValue(incoming.Kind, out var merged))
|
||||
{
|
||||
Kinds[incoming.Kind] = incoming;
|
||||
incoming.Fields ??= new List<FieldSchema>();
|
||||
return;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(incoming.Name))
|
||||
merged.Name = incoming.Name;
|
||||
if (!string.IsNullOrWhiteSpace(incoming.Description))
|
||||
merged.Description = incoming.Description;
|
||||
if (!string.IsNullOrWhiteSpace(incoming.Folder))
|
||||
merged.Folder = incoming.Folder;
|
||||
if (!string.IsNullOrWhiteSpace(incoming.NewId))
|
||||
merged.NewId = incoming.NewId;
|
||||
if (incoming.Order != 0)
|
||||
merged.Order = incoming.Order;
|
||||
foreach (var field in incoming.Fields ?? new List<FieldSchema>())
|
||||
{
|
||||
var at = merged.Fields.FindIndex(f => string.Equals(f.Key, field.Key, StringComparison.OrdinalIgnoreCase));
|
||||
if (at >= 0)
|
||||
merged.Fields[at] = field;
|
||||
else
|
||||
merged.Fields.Add(field);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Stamp(List<FieldSchema> fields, string modId)
|
||||
{
|
||||
foreach (var field in fields?.Where(f => f != null) ?? Enumerable.Empty<FieldSchema>())
|
||||
{
|
||||
field.SourceMod = modId;
|
||||
Stamp(field.Of, modId);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<(string, string, string)> Read(Dictionary<string, FileEntry> files)
|
||||
{
|
||||
if (files == null)
|
||||
yield break;
|
||||
var order = ModRegistry.Instance.GetOrderedModIds().Select((id, i) => (id, i)).ToDictionary(p => p.id, p => p.i, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in files.Where(p => p.Key.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(p => order.TryGetValue(p.Value.ModId ?? string.Empty, out var i) ? i : int.MaxValue)
|
||||
.ThenBy(p => p.Key, StringComparer.Ordinal))
|
||||
{
|
||||
yield return (pair.Value.ModId, pair.Value.FullPath, ModManager.Instance.ReadFileText(pair.Value.ModId, pair.Value.FullPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
/// <summary>
|
||||
/// Checks values against field schemas: unknown parameters, missing required ones,
|
||||
/// numbers that are not numbers, choices outside their options and references to
|
||||
/// entities that do not exist. The same checks feed the validator report (files on
|
||||
/// disk) and the command palette (a line being built), so an author sees in the
|
||||
/// editor what the report would say.
|
||||
/// <para/>
|
||||
/// Existence is asked through a callback, because the validator knows entities from
|
||||
/// the mods' files and the palette from the running game; a null callback skips it.
|
||||
/// </summary>
|
||||
public static class SchemaChecker
|
||||
{
|
||||
private static readonly string[] BoolWords = { "0", "1", "true", "false", "yes", "no" };
|
||||
|
||||
/// <summary>Checks named values of <paramref name="owner"/> (an action or command name, for messages).</summary>
|
||||
public static List<SchemaIssue> CheckNamed(string owner, IReadOnlyList<FieldSchema> fields, IReadOnlyDictionary<string, string> named, Func<string, string, bool> exists)
|
||||
{
|
||||
var issues = new List<SchemaIssue>();
|
||||
fields ??= new List<FieldSchema>();
|
||||
named ??= new Dictionary<string, string>();
|
||||
foreach (var key in named.Keys)
|
||||
{
|
||||
if (!fields.Any(f => string.Equals(f.Key, key, StringComparison.OrdinalIgnoreCase)))
|
||||
issues.Add(new SchemaIssue(false, "SCHEMA_UNKNOWN_PARAM", $"'{owner}' has no parameter '{key}' (known: {string.Join(", ", fields.Select(f => f.Key))})"));
|
||||
}
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var value = named.FirstOrDefault(p => string.Equals(p.Key, field.Key, StringComparison.OrdinalIgnoreCase)).Value;
|
||||
issues.AddRange(CheckValue(owner, field, value, exists));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// <summary>Checks positional values in order against <paramref name="fields"/>.</summary>
|
||||
public static List<SchemaIssue> CheckPositional(string owner, IReadOnlyList<FieldSchema> fields, IReadOnlyList<string> positional, Func<string, string, bool> exists)
|
||||
{
|
||||
var issues = new List<SchemaIssue>();
|
||||
fields ??= new List<FieldSchema>();
|
||||
for (var i = 0; i < fields.Count; i++)
|
||||
issues.AddRange(CheckValue(owner, fields[i], positional != null && i < positional.Count ? positional[i] : null, exists));
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// <summary>Checks one value (null or empty = not given).</summary>
|
||||
public static List<SchemaIssue> CheckValue(string owner, FieldSchema field, string value, Func<string, string, bool> exists)
|
||||
{
|
||||
var issues = new List<SchemaIssue>();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
if (field.Required)
|
||||
issues.Add(new SchemaIssue(true, "SCHEMA_MISSING_PARAM", $"'{owner}' needs '{field.Key}'"));
|
||||
return issues;
|
||||
}
|
||||
value = value.Trim();
|
||||
switch (field.Type?.ToLowerInvariant())
|
||||
{
|
||||
case "int":
|
||||
if (!int.TryParse(value, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out _))
|
||||
issues.Add(new SchemaIssue(true, "SCHEMA_BAD_VALUE", $"'{owner}': '{field.Key}' must be a whole number, got '{value}'"));
|
||||
break;
|
||||
case "number":
|
||||
if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
|
||||
issues.Add(new SchemaIssue(true, "SCHEMA_BAD_VALUE", $"'{owner}': '{field.Key}' must be a number, got '{value}'"));
|
||||
break;
|
||||
case "bool":
|
||||
if (!BoolWords.Contains(value.ToLowerInvariant()))
|
||||
issues.Add(new SchemaIssue(false, "SCHEMA_BAD_VALUE", $"'{owner}': '{field.Key}' should be 1 or 0, got '{value}'"));
|
||||
break;
|
||||
case "choice":
|
||||
if (field.Options is { Count: > 0 } && !field.Options.Contains(value, StringComparer.OrdinalIgnoreCase))
|
||||
issues.Add(new SchemaIssue(false, "SCHEMA_BAD_VALUE", $"'{owner}': '{field.Key}' is one of {string.Join(", ", field.Options)}, got '{value}'"));
|
||||
break;
|
||||
case "ref":
|
||||
CheckRef(owner, field, field.Kind, value, exists, issues);
|
||||
break;
|
||||
case "ref_list":
|
||||
foreach (var part in value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
CheckRef(owner, field, field.Kind, part, exists, issues);
|
||||
break;
|
||||
case "who":
|
||||
if (!string.Equals(value, "player", StringComparison.OrdinalIgnoreCase) && !string.Equals(value, "party", StringComparison.OrdinalIgnoreCase))
|
||||
CheckRef(owner, field, "character", value, exists, issues);
|
||||
break;
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
private static void CheckRef(string owner, FieldSchema field, string kind, string id, Func<string, string, bool> exists, List<SchemaIssue> issues)
|
||||
{
|
||||
if (exists == null || string.IsNullOrWhiteSpace(kind))
|
||||
return;
|
||||
if (!exists(kind, id))
|
||||
issues.Add(new SchemaIssue(true, "SCHEMA_UNKNOWN_REF", $"'{owner}': '{field.Key}' names {kind} '{id}', which no loaded mod defines"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// One <c>schemas/*.json</c> file of a mod. Any mod may ship one, with no dependency on
|
||||
/// the schema tools: without them the file is simply never read.
|
||||
/// </summary>
|
||||
public class SchemaFile
|
||||
{
|
||||
/// <summary>Described scenario actions.</summary>
|
||||
public List<ActionSchema> Actions { get; set; } = new();
|
||||
|
||||
/// <summary>Described DSL commands.</summary>
|
||||
public List<CommandSchema> Commands { get; set; } = new();
|
||||
|
||||
/// <summary>Described entity kinds (fields merge across files).</summary>
|
||||
public List<KindSchema> Kinds { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
/// <summary>One problem a schema finds in a line or an entity: an error breaks the game, a warning is likely a typo.</summary>
|
||||
public class SchemaIssue
|
||||
{
|
||||
/// <summary>Creates an issue.</summary>
|
||||
public SchemaIssue(bool isError, string code, string message)
|
||||
{
|
||||
IsError = isError;
|
||||
Code = code;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
/// <summary>True for an error, false for a warning.</summary>
|
||||
public bool IsError { get; }
|
||||
|
||||
/// <summary>Stable code (<c>SCHEMA_MISSING_PARAM</c>…), what reports and tests match on.</summary>
|
||||
public string Code { get; }
|
||||
|
||||
/// <summary>English text for the report.</summary>
|
||||
public string Message { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
/// <summary>
|
||||
/// Between a form's text value and the JSON node of a field: numbers stay numbers, bools
|
||||
/// stay bools, a <c>ref_list</c> is an array of ids, a <c>map</c> value is a number when
|
||||
/// it is one and a formula text otherwise — the shapes the engine's JSON readers expect.
|
||||
/// </summary>
|
||||
public static class SchemaJson
|
||||
{
|
||||
/// <summary>The node for <paramref name="text"/> of a field (null for an empty value).</summary>
|
||||
public static JsonNode ToNode(FieldSchema field, string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return null;
|
||||
text = text.Trim();
|
||||
switch (field.Type?.ToLowerInvariant())
|
||||
{
|
||||
case "int":
|
||||
return int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var i) ? JsonValue.Create(i) : JsonValue.Create(text);
|
||||
case "number":
|
||||
return NumberOrText(text);
|
||||
case "bool":
|
||||
return JsonValue.Create(text is "1" or "true" or "True" or "yes");
|
||||
case "ref_list":
|
||||
return new JsonArray(text.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(s => (JsonNode)JsonValue.Create(s)).ToArray());
|
||||
case "json":
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(text);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return JsonValue.Create(text);
|
||||
}
|
||||
default:
|
||||
return JsonValue.Create(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A number node when <paramref name="text"/> is a number, else the text (a formula).</summary>
|
||||
public static JsonNode NumberOrText(string text)
|
||||
{
|
||||
if (int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var i))
|
||||
return JsonValue.Create(i);
|
||||
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
|
||||
return JsonValue.Create(d);
|
||||
return JsonValue.Create(text);
|
||||
}
|
||||
|
||||
/// <summary>The text of a node for a form (an array of strings joins with commas).</summary>
|
||||
public static string ToText(JsonNode node)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case null:
|
||||
return string.Empty;
|
||||
case JsonArray array when array.All(n => n is JsonValue):
|
||||
return string.Join(",", array.Select(n => n?.ToString()));
|
||||
case JsonValue value when value.TryGetValue(out bool b):
|
||||
return b ? "1" : string.Empty;
|
||||
case JsonValue value when value.TryGetValue(out double d):
|
||||
return d.ToString(CultureInfo.InvariantCulture);
|
||||
case JsonValue value:
|
||||
return value.ToString();
|
||||
default:
|
||||
return node.ToJsonString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
/// <summary>
|
||||
/// Writes scenario lines from schema values, in the DSL's own syntax: values with spaces
|
||||
/// or quotes are quoted (inner quotes escaped; a named pair is quoted whole,
|
||||
/// <c>"text=two words"</c>), empty values are left out, named
|
||||
/// parameters follow the schema's order, the arrow target comes last. What the palette
|
||||
/// inserts is exactly what an author would have typed, so the text stays the source of
|
||||
/// truth and every other tool (validator, studio, atlases) reads it unchanged.
|
||||
/// </summary>
|
||||
public static class SchemaLine
|
||||
{
|
||||
/// <summary><c>action <id> k=v …</c> for an action schema.</summary>
|
||||
public static string Action(ActionSchema schema, IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
var line = new StringBuilder("action ").Append(schema.Id);
|
||||
AppendNamed(line, schema.Params, values);
|
||||
return line.ToString();
|
||||
}
|
||||
|
||||
/// <summary>A command line: positional values in order, then named ones, then <c>-> :label</c>.</summary>
|
||||
public static string Command(CommandSchema schema, IReadOnlyDictionary<string, string> values, string arrow)
|
||||
{
|
||||
var line = new StringBuilder(schema.Id);
|
||||
foreach (var field in schema.Positional ?? new List<FieldSchema>())
|
||||
{
|
||||
var value = Get(values, field.Key);
|
||||
if (string.IsNullOrEmpty(value))
|
||||
continue;
|
||||
line.Append(' ').Append(Quote(value));
|
||||
}
|
||||
AppendNamed(line, schema.Params, values);
|
||||
if (!string.IsNullOrWhiteSpace(arrow))
|
||||
line.Append(" -> :").Append(arrow.Trim().TrimStart(':'));
|
||||
return line.ToString();
|
||||
}
|
||||
|
||||
/// <summary>A value as one DSL token: as it is when it has no spaces or quotes, else quoted.</summary>
|
||||
public static string Quote(string value)
|
||||
{
|
||||
if (value.Length > 0 && !value.Any(c => char.IsWhiteSpace(c) || c == '"'))
|
||||
return value;
|
||||
return "\"" + value.Replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
|
||||
private static void AppendNamed(StringBuilder line, List<FieldSchema> fields, IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
foreach (var field in fields ?? new List<FieldSchema>())
|
||||
{
|
||||
var value = Get(values, field.Key);
|
||||
if (string.IsNullOrEmpty(value))
|
||||
continue;
|
||||
// The DSL splits a token at its first '=' after reading it whole, and quotes
|
||||
// only work at the start of a token: a value with spaces quotes the pair.
|
||||
line.Append(' ').Append(Quote(field.Key + "=" + value));
|
||||
}
|
||||
}
|
||||
|
||||
private static string Get(IReadOnlyDictionary<string, string> values, string key)
|
||||
=> key != null && values != null && values.TryGetValue(key, out var value) ? value?.Trim() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Cthangover.Mods.Schemas
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// The entity references a value makes, by its field type: a <c>ref</c> names one entity
|
||||
/// of the field's kind, a <c>ref_list</c> a comma list, a <c>who</c> a character unless it
|
||||
/// is <c>player</c> or <c>party</c>, a <c>scene</c> a logical scene. Values built at run
|
||||
/// time (<c>${var}</c>) are no references. The validator turns these into graph
|
||||
/// references, the same graph its own strategies fill, so missing targets are reported
|
||||
/// once, by one rule.
|
||||
/// </summary>
|
||||
public static class SchemaReferences
|
||||
{
|
||||
/// <summary>The kind of a scene reference.</summary>
|
||||
public const string SceneKind = "scene";
|
||||
|
||||
/// <summary>(kind, id) pairs <paramref name="value"/> of <paramref name="field"/> refers to.</summary>
|
||||
public static IEnumerable<(string Kind, string Id)> Of(FieldSchema field, string value)
|
||||
{
|
||||
if (field == null || string.IsNullOrWhiteSpace(value) || value.Contains("${", StringComparison.Ordinal))
|
||||
yield break;
|
||||
value = value.Trim();
|
||||
switch (field.Type?.ToLowerInvariant())
|
||||
{
|
||||
case "ref" when !string.IsNullOrWhiteSpace(field.Kind):
|
||||
yield return (field.Kind, value);
|
||||
break;
|
||||
case "ref_list" when !string.IsNullOrWhiteSpace(field.Kind):
|
||||
foreach (var part in value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
yield return (field.Kind, part);
|
||||
break;
|
||||
case "who" when !string.Equals(value, "player", StringComparison.OrdinalIgnoreCase) && !string.Equals(value, "party", StringComparison.OrdinalIgnoreCase):
|
||||
yield return ("character", value);
|
||||
break;
|
||||
case "scene":
|
||||
yield return (SceneKind, value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user