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:
cthangoverandClaude Opus 5.5 committed 2026-10-24 10:01:15 +02:00
commit ce15eccb0f
27 files changed
+1038

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
namespace Cthangover.Mods.Schemas
{
using System.Collections.Generic;
/// <summary>
/// A scenario action (<c>action &lt;id&gt; 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; }
}
}
+35
View File
@@ -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>-&gt; :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>-&gt; :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; }
}
}
+61
View File
@@ -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; }
}
}
+111
View File
@@ -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.
}
}
}
}
+35
View File
@@ -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>&lt;Folder&gt;/&lt;mod&gt;.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();
}
}
+161
View File
@@ -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));
}
}
}
}
+103
View File
@@ -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"));
}
}
}
+20
View File
@@ -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();
}
}
+23
View File
@@ -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; }
}
}
+75
View File
@@ -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();
}
}
}
}
+66
View File
@@ -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 &lt;id&gt; 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>-&gt; :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;
}
}
+43
View File
@@ -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;
}
}
}
}