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,30 @@
|
|||||||
|
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
||||||
|
# Binaries: never converted, never diffed as text.
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.webp binary
|
||||||
|
*.svg text
|
||||||
|
*.ogg binary
|
||||||
|
*.wav binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.dll binary
|
||||||
|
*.so binary
|
||||||
|
*.dylib binary
|
||||||
|
*.pdb binary
|
||||||
|
*.exe binary
|
||||||
|
*.zip binary
|
||||||
|
*.pck binary
|
||||||
|
*.moc3 binary
|
||||||
|
*.res binary
|
||||||
|
*.scn binary
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# Godot 4.x
|
||||||
|
.godot/
|
||||||
|
.idea/
|
||||||
|
sdk/
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
|
||||||
|
#ai
|
||||||
|
.omo/
|
||||||
|
.opencode/
|
||||||
|
|
||||||
|
gen/
|
||||||
|
obj/
|
||||||
|
bin/
|
||||||
|
|
||||||
|
*.tres~
|
||||||
|
*.tscn~
|
||||||
|
|
||||||
|
# GDScript LSP
|
||||||
|
.godot/
|
||||||
|
|
||||||
|
# Export
|
||||||
|
*.exe
|
||||||
|
*.dmg
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.pck
|
||||||
|
*.zip
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
*.import
|
||||||
|
*.godot.uid
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_schema_base_name = Schemas
|
||||||
|
mod_schema_base_desc = Annotations as data: schemas/*.json files of any mod describe scenario actions and commands (parameters, types, entity references) and the fields of entity kinds — their own and other mods'. The catalog merges them in load order, the checker validates values, the line writer builds scenario lines, the index knows the ids of any kind. The validator, the data editors and the Data Editor window build on it.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_schema_base_name = Esquemas
|
||||||
|
mod_schema_base_desc = Anotaciones como datos: los archivos schemas/*.json de cualquier mod describen acciones y comandos de escenarios (parámetros, tipos, referencias a entidades) y los campos de los tipos de entidades, propios y ajenos. El catálogo los fusiona por orden de carga, el comprobador valida valores, el escritor arma líneas de escenario y el índice conoce los id de cualquier tipo. Sobre esto se apoyan el validador, los editores de datos y la ventana Editor de datos.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_schema_base_name = スキーマ
|
||||||
|
mod_schema_base_desc = データとしての注釈:どの MOD の schemas/*.json もシナリオの行動とコマンド(パラメーター、型、エンティティ参照)とエンティティの種類のフィールドを記述する——自分のものも他の MOD のものも。カタログが読み込み順に統合し、チェッカーが値を検査し、行ライターがシナリオの行を書き、インデックスがあらゆる種類の id を知る。バリデーター、データエディター、「データエディター」ウィンドウがこれに乗っている。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_schema_base_name = 스키마
|
||||||
|
mod_schema_base_desc = 데이터로 된 주석: 어떤 모드의 schemas/*.json이든 시나리오의 행동과 명령(매개변수, 유형, 엔티티 참조), 엔티티 종류의 필드를 설명합니다 — 자기 것이든 다른 모드의 것이든. 카탈로그가 로드 순서대로 합치고, 검사기가 값을 검증하고, 줄 작성기가 시나리오 줄을 쓰고, 색인이 모든 종류의 id를 압니다. 검증기, 데이터 에디터, ‘데이터 에디터’ 창이 이 위에 있습니다.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_schema_base_name = Схемы
|
||||||
|
mod_schema_base_desc = Аннотации как данные: файлы schemas/*.json любого мода описывают действия и команды сценариев (параметры, типы, ссылки на сущности) и поля видов сущностей — свои и чужие. Каталог сливает их по порядку загрузки, проверка сверяет значения, построитель пишет строки сценария, индекс знает id любого вида. На этом стоят валидатор, редакторы данных и окно «Редактор данных».
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_schema_base_name = Lược đồ
|
||||||
|
mod_schema_base_desc = Chú thích dưới dạng dữ liệu: tệp schemas/*.json của mọi mod mô tả hành động và lệnh kịch bản (tham số, kiểu, tham chiếu thực thể) cùng các trường của loại thực thể — của mình hay của mod khác. Danh mục gộp chúng theo thứ tự tải, bộ kiểm tra soát giá trị, bộ viết tạo dòng kịch bản, chỉ mục biết id của mọi loại. Trình kiểm tra, các trình sửa dữ liệu và cửa sổ Trình sửa dữ liệu đều dựa trên nó.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_schema_base_name = 模式
|
||||||
|
mod_schema_base_desc = 数据化的注解:任何模组的 schemas/*.json 文件描述剧本的动作和命令(参数、类型、实体引用)以及实体类型的字段——自己的和其他模组的。目录按加载顺序合并它们,检查器验证数值,行生成器写出剧本行,索引知道任何类型的 id。验证器、数据编辑器和“数据编辑器”窗口都建立在其上。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "schema_base",
|
||||||
|
"name": "Schemas",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "ct",
|
||||||
|
"description": "Annotations as data: schemas/*.json files any mod ships describe scenario actions, DSL commands (parameters, types, entity references) and the fields of entity kinds — its own or another mod's. The catalog merges them in load order (an action or command is replaced whole, a kind is merged field by field), the checker validates values, the line writer builds DSL lines, the entity index knows the ids of any kind. The validator, the data editors and the Data Editor window build on it.",
|
||||||
|
"category": "engine, library",
|
||||||
|
"sources": [
|
||||||
|
"src/**/*.cs"
|
||||||
|
],
|
||||||
|
"test_sources": [
|
||||||
|
"tests/*.cs"
|
||||||
|
],
|
||||||
|
"depends": []
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
namespace Cthangover.Mods.Schemas.Tests
|
||||||
|
{
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merging schema files of several mods: the later mod replaces an action or command
|
||||||
|
/// whole and a kind's field by key, and adds fields of its own; broken files are reported.
|
||||||
|
/// </summary>
|
||||||
|
public class SchemaCatalogTests
|
||||||
|
{
|
||||||
|
private const string First = "{\"Actions\":[{\"Id\":\"x.do\",\"Params\":[{\"Key\":\"a\"}]}],\"Kinds\":[{\"Kind\":\"item\",\"Name\":\"k/item\",\"Folder\":\"items\",\"Fields\":[{\"Key\":\"Name\",\"Type\":\"locale\"},{\"Key\":\"Cost\",\"Type\":\"text\"}]}]}";
|
||||||
|
private const string Second = "{\"actions\":[{\"id\":\"x.do\",\"params\":[{\"key\":\"b\",\"type\":\"int\"}]}],\"kinds\":[{\"kind\":\"ITEM\",\"fields\":[{\"key\":\"cost\",\"type\":\"int\"},{\"key\":\"Equip\"}]}],\"commands\":[{\"id\":\"check\",\"arrow\":\"k/arrow\"}]}";
|
||||||
|
|
||||||
|
[ModTest("catalog/later_mod_wins_and_fields_merge")]
|
||||||
|
static void LaterModWinsAndFieldsMerge(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var catalog = SchemaCatalog.Build(new List<(string, string, string)> { ("mod_a", "schemas/a.json", First), ("mod_b", "schemas/b.json", Second) });
|
||||||
|
|
||||||
|
ModAssert.Equal(0, catalog.Errors.Count, "both files parse (keys in any case)");
|
||||||
|
var action = catalog.Actions["X.DO"];
|
||||||
|
ModAssert.Equal("mod_b", action.SourceMod, "the later action replaced the earlier");
|
||||||
|
ModAssert.Equal(1, action.Params.Count, "replaced whole, not merged");
|
||||||
|
ModAssert.Equal("b", action.Params[0].Key, "the later parameter");
|
||||||
|
|
||||||
|
var item = catalog.Kinds["item"];
|
||||||
|
ModAssert.Equal("k/item", item.Name, "a kind keeps the name nobody replaced");
|
||||||
|
ModAssert.Equal("items", item.Folder, "and the folder");
|
||||||
|
ModAssert.Equal(3, item.Fields.Count, "Name, Cost, Equip");
|
||||||
|
ModAssert.Equal("Name", item.Fields[0].Key, "order kept");
|
||||||
|
ModAssert.Equal("int", item.Fields[1].Type, "a field with the same key is replaced in place");
|
||||||
|
ModAssert.Equal("mod_b", item.Fields[1].SourceMod, "and remembers who described it");
|
||||||
|
ModAssert.Equal("Equip", item.Fields[2].Key, "a new field is appended");
|
||||||
|
ModAssert.Equal("mod_a", item.Fields[0].SourceMod, "the untouched field keeps its source");
|
||||||
|
ModAssert.NotNull(catalog.Commands["check"], "commands are read too");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("catalog/broken_files_are_reported")]
|
||||||
|
static void BrokenFilesAreReported(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var catalog = SchemaCatalog.Build(new List<(string, string, string)> { ("mod_a", "schemas/a.json", "{ not json"), ("mod_b", "schemas/b.json", First) });
|
||||||
|
|
||||||
|
ModAssert.Equal(1, catalog.Errors.Count, "one broken file");
|
||||||
|
ModAssert.True(catalog.Errors[0].StartsWith("mod_a:schemas/a.json"), "named by mod and path");
|
||||||
|
ModAssert.NotNull(catalog.Actions["x.do"], "the good file still counts");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("catalog/loaded_mods_describe_their_actions_and_kinds")]
|
||||||
|
static void LoadedModsDescribe(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var catalog = SchemaCatalog.Current;
|
||||||
|
ModAssert.Equal(0, catalog.Errors.Count, "every shipped schema parses: " + string.Join("; ", catalog.Errors));
|
||||||
|
foreach (var id in new[] { "stat.add", "stat.exp", "equip.put", "item.add", "rpg.battle", "trade.open" })
|
||||||
|
ModAssert.True(catalog.Actions.ContainsKey(id), $"action {id} is described");
|
||||||
|
foreach (var id in new[] { "check", "when", "text", "switch_scene" })
|
||||||
|
ModAssert.True(catalog.Commands.ContainsKey(id), $"command {id} is described");
|
||||||
|
var item = catalog.FieldsOf("item").Select(f => f.Key).ToList();
|
||||||
|
ModAssert.True(item.Contains("Cost") && item.Contains("Equip") && item.Contains("Traits"), "items: the tools' own fields and the equipment mod's merged");
|
||||||
|
var character = catalog.FieldsOf("character").Select(f => f.Key).ToList();
|
||||||
|
ModAssert.True(character.Contains("Health") && character.Contains("Archetype") && character.Contains("Stats"), "characters: builtin and stats fields merged");
|
||||||
|
ModAssert.True(catalog.FieldsOf("action").Any(f => f.Key == "Values" && f.Of.Any(o => o.Key == "Formula")), "battle formulas describe action values");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
namespace Cthangover.Mods.Schemas.Tests
|
||||||
|
{
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
|
||||||
|
/// <summary>What the validator and the palette report about values.</summary>
|
||||||
|
public class SchemaCheckerTests
|
||||||
|
{
|
||||||
|
private static readonly List<FieldSchema> Fields = new()
|
||||||
|
{
|
||||||
|
new() { Key = "id", Type = "ref", Kind = "item", Required = true },
|
||||||
|
new() { Key = "count", Type = "int" },
|
||||||
|
new() { Key = "rate", Type = "number" },
|
||||||
|
new() { Key = "mode", Type = "choice", Options = new List<string> { "fast", "slow" } },
|
||||||
|
new() { Key = "on", Type = "bool" },
|
||||||
|
new() { Key = "who", Type = "who" },
|
||||||
|
new() { Key = "enemies", Type = "ref_list", Kind = "character" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool Exists(string kind, string id) => kind == "item" && id == "sword" || kind == "character" && id is "hero" or "wolf";
|
||||||
|
|
||||||
|
private static List<string> Codes(Dictionary<string, string> named) => SchemaChecker.CheckNamed("t", Fields, named, Exists).Select(i => (i.IsError ? "E:" : "W:") + i.Code).ToList();
|
||||||
|
|
||||||
|
[ModTest("checker/clean_values_pass")]
|
||||||
|
static void CleanValuesPass(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var codes = Codes(new Dictionary<string, string> { ["id"] = "sword", ["count"] = "-2", ["rate"] = "0.5", ["mode"] = "FAST", ["on"] = "1", ["who"] = "party", ["enemies"] = "hero, wolf" });
|
||||||
|
ModAssert.Equal(0, codes.Count, "no issue: " + string.Join(",", codes));
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("checker/each_problem_is_named")]
|
||||||
|
static void EachProblemIsNamed(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
ModAssert.True(Codes(new Dictionary<string, string>()).SequenceEqual(new[] { "E:SCHEMA_MISSING_PARAM" }), "missing required");
|
||||||
|
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "sword", ["nope"] = "1" }).SequenceEqual(new[] { "W:SCHEMA_UNKNOWN_PARAM" }), "unknown parameter");
|
||||||
|
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "axe" }).SequenceEqual(new[] { "E:SCHEMA_UNKNOWN_REF" }), "unknown entity");
|
||||||
|
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "sword", ["count"] = "1.5" }).SequenceEqual(new[] { "E:SCHEMA_BAD_VALUE" }), "not a whole number");
|
||||||
|
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "sword", ["mode"] = "medium" }).SequenceEqual(new[] { "W:SCHEMA_BAD_VALUE" }), "outside the options");
|
||||||
|
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "sword", ["who"] = "ghost" }).SequenceEqual(new[] { "E:SCHEMA_UNKNOWN_REF" }), "an unknown character as who");
|
||||||
|
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "sword", ["enemies"] = "wolf,bear" }).SequenceEqual(new[] { "E:SCHEMA_UNKNOWN_REF" }), "one unknown in a list");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("checker/no_callback_skips_references")]
|
||||||
|
static void NoCallbackSkipsReferences(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var issues = SchemaChecker.CheckNamed("t", Fields, new Dictionary<string, string> { ["id"] = "anything" }, null);
|
||||||
|
ModAssert.Equal(0, issues.Count, "references are not checked without a callback");
|
||||||
|
var positional = SchemaChecker.CheckPositional("t", Fields.Take(2).ToList(), new List<string> { "sword", "x" }, Exists);
|
||||||
|
ModAssert.Equal(1, positional.Count, "positional values are checked by index");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Cthangover.Mods.Schemas.Tests
|
||||||
|
{
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
|
||||||
|
/// <summary>Form text and JSON nodes convert into the shapes the engine's readers expect.</summary>
|
||||||
|
public class SchemaJsonTests
|
||||||
|
{
|
||||||
|
[ModTest("json/typed_nodes")]
|
||||||
|
static void TypedNodes(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
ModAssert.Equal("5", SchemaJson.ToNode(new FieldSchema { Type = "int" }, "5").ToJsonString(), "int");
|
||||||
|
ModAssert.Equal("2.5", SchemaJson.ToNode(new FieldSchema { Type = "number" }, "2.5").ToJsonString(), "number");
|
||||||
|
ModAssert.Equal("true", SchemaJson.ToNode(new FieldSchema { Type = "bool" }, "1").ToJsonString(), "bool");
|
||||||
|
ModAssert.Equal("[\"a\",\"b\"]", SchemaJson.ToNode(new FieldSchema { Type = "ref_list" }, "a, b").ToJsonString(), "list of ids");
|
||||||
|
ModAssert.Equal("str + 1", SchemaJson.ToNode(new FieldSchema { Type = "formula" }, " str + 1 ").GetValue<string>(), "formula text, trimmed");
|
||||||
|
ModAssert.Null(SchemaJson.ToNode(new FieldSchema { Type = "int" }, " "), "empty removes the key");
|
||||||
|
ModAssert.Equal("7", SchemaJson.NumberOrText("7").ToJsonString(), "a map value that is a number");
|
||||||
|
ModAssert.Equal("6 + level", SchemaJson.NumberOrText("6 + level").GetValue<string>(), "a map value that is a formula");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("json/texts_for_the_form")]
|
||||||
|
static void TextsForTheForm(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var node = JsonNode.Parse("{\"a\": 3, \"b\": true, \"c\": [\"x\", \"y\"], \"d\": \"t\", \"e\": {\"k\": 1}}");
|
||||||
|
ModAssert.Equal("3", SchemaJson.ToText(node["a"]), "number");
|
||||||
|
ModAssert.Equal("1", SchemaJson.ToText(node["b"]), "true");
|
||||||
|
ModAssert.Equal("x,y", SchemaJson.ToText(node["c"]), "array of strings");
|
||||||
|
ModAssert.Equal("t", SchemaJson.ToText(node["d"]), "string");
|
||||||
|
ModAssert.Equal("{\"k\":1}", SchemaJson.ToText(node["e"]), "object as JSON");
|
||||||
|
ModAssert.Equal("", SchemaJson.ToText(null), "absent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Cthangover.Mods.Schemas.Tests
|
||||||
|
{
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
|
||||||
|
/// <summary>Which entity references a value makes, by its field type.</summary>
|
||||||
|
public class SchemaReferencesTests
|
||||||
|
{
|
||||||
|
private static string Refs(FieldSchema field, string value)
|
||||||
|
=> string.Join(";", SchemaReferences.Of(field, value).Select(r => r.Kind + ":" + r.Id));
|
||||||
|
|
||||||
|
[ModTest("references/by_type")]
|
||||||
|
static void ByType(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
ModAssert.Equal("item:sword", Refs(new FieldSchema { Type = "ref", Kind = "item" }, " sword "), "ref");
|
||||||
|
ModAssert.Equal("character:a;character:b", Refs(new FieldSchema { Type = "ref_list", Kind = "character" }, "a, b,"), "ref_list");
|
||||||
|
ModAssert.Equal("character:hero", Refs(new FieldSchema { Type = "who" }, "hero"), "who names a character");
|
||||||
|
ModAssert.Equal("", Refs(new FieldSchema { Type = "who" }, "party"), "party is no reference");
|
||||||
|
ModAssert.Equal("", Refs(new FieldSchema { Type = "who" }, "Player"), "player is no reference");
|
||||||
|
ModAssert.Equal("scene:town", Refs(new FieldSchema { Type = "scene" }, "town"), "scene");
|
||||||
|
ModAssert.Equal("", Refs(new FieldSchema { Type = "text" }, "anything"), "text makes none");
|
||||||
|
ModAssert.Equal("", Refs(new FieldSchema { Type = "ref" }, "x"), "a ref without a kind makes none");
|
||||||
|
ModAssert.Equal("", Refs(new FieldSchema { Type = "ref", Kind = "item" }, "${picked}"), "a run-time value makes none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user