CI / build-test (push) Successful in 1m12s
Three regex-powered content tools (phase G5):
- Formula group functions gsum/gcount/gavg/gmin/gmax('regex') aggregate
over every context variable whose name matches the pattern, e.g.
gsum('leaf_.*'). Adds string literals to the formula grammar and an
IFormulaContext.ResolveMatching hook; GenomeContext enumerates matching
genes, so a trait can sum/average a gene group.
- DefDatabase content patches: a { "type": "Patch", patches:[{ defType,
match (regex on defName), set:{fields} }] } file sets fields on every
matching raw def before resolution — mods patch Core in bulk.
- DefDatabase.RegisterValidator(typeKey, field, regex): load-time check
that a string field matches a pattern, throwing otherwise (naming/format
conventions).
Covered by FormulaTests (group aggregation, composition, bad calls) and
DefDatabaseTests (patch set/match/unknown-type, validator pass/reject).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
377 lines
13 KiB
C#
377 lines
13 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace MrGameEng.Mods;
|
|
|
|
/// <summary>
|
|
/// Database of data definitions loaded from mod JSON. Each file under a mod's
|
|
/// <c>Defs/</c> folder is an envelope <c>{ "type": "<key>", "defs": [ … ] }</c>; the
|
|
/// game registers the CLR type for every key before <see cref="Load"/>. Defs from later
|
|
/// mods replace same-named defs of earlier mods; <c>parent</c> chains are merged
|
|
/// field-by-field (own fields win, nested objects are replaced whole); defs marked
|
|
/// <c>abstract</c> serve only as parents.
|
|
/// </summary>
|
|
public sealed class DefDatabase
|
|
{
|
|
private sealed class TypeEntry
|
|
{
|
|
public required string Key;
|
|
public required Type ClrType;
|
|
public readonly Dictionary<string, JsonObject> Raw = new(StringComparer.Ordinal);
|
|
public readonly SortedDictionary<string, Def> Resolved = new(StringComparer.Ordinal);
|
|
}
|
|
|
|
private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<Type, TypeEntry> _byType = [];
|
|
|
|
/// <summary>Reserved def-file <c>"type"</c> that carries content patches rather than defs.</summary>
|
|
public const string PatchTypeKey = "Patch";
|
|
|
|
private sealed record PatchRule(string DefType, Regex Match, JsonObject Set);
|
|
|
|
private sealed record ValidationRule(string Field, Regex Pattern, string Description);
|
|
|
|
private readonly List<PatchRule> _patches = [];
|
|
private readonly Dictionary<string, List<ValidationRule>> _validators = new(
|
|
StringComparer.OrdinalIgnoreCase
|
|
);
|
|
|
|
private static readonly JsonDocumentOptions DocumentOptions = new()
|
|
{
|
|
CommentHandling = JsonCommentHandling.Skip,
|
|
AllowTrailingCommas = true,
|
|
};
|
|
|
|
/// <summary>Registers the CLR type behind a def-type key (the <c>"type"</c> field of def files).</summary>
|
|
public void RegisterType<T>(string typeKey)
|
|
where T : Def
|
|
{
|
|
var entry = new TypeEntry { Key = typeKey, ClrType = typeof(T) };
|
|
if (!_byKey.TryAdd(typeKey, entry))
|
|
{
|
|
throw new InvalidOperationException($"Def type '{typeKey}' is already registered.");
|
|
}
|
|
|
|
_byType.Add(typeof(T), entry);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a load-time validation: the string <paramref name="field"/> of every resolved def of
|
|
/// type <paramref name="typeKey"/> must match <paramref name="pattern"/> (a regex), or
|
|
/// <see cref="Load"/> throws. Non-string or absent fields are skipped. Use it to enforce naming
|
|
/// conventions (e.g. gene ids start with <c>Gene</c>) or key/format rules across a mod's content.
|
|
/// </summary>
|
|
public void RegisterValidator(
|
|
string typeKey,
|
|
string field,
|
|
string pattern,
|
|
string? description = null
|
|
)
|
|
{
|
|
Regex regex;
|
|
try
|
|
{
|
|
regex = new Regex(pattern, RegexOptions.CultureInvariant);
|
|
}
|
|
catch (ArgumentException error)
|
|
{
|
|
throw new ArgumentException($"Invalid validator regex '{pattern}': {error.Message}");
|
|
}
|
|
|
|
if (!_validators.TryGetValue(typeKey, out var list))
|
|
{
|
|
list = [];
|
|
_validators[typeKey] = list;
|
|
}
|
|
|
|
list.Add(new ValidationRule(field, regex, description ?? $"pattern /{pattern}/"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and
|
|
/// resolves inheritance. Call once after registering all def types.
|
|
/// </summary>
|
|
public void Load(IReadOnlyList<Mod> mods)
|
|
{
|
|
foreach (var mod in mods)
|
|
{
|
|
var defsDir = mod.ContentPath("Defs");
|
|
if (!Directory.Exists(defsDir))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var files = Directory
|
|
.EnumerateFiles(defsDir, "*.json", SearchOption.AllDirectories)
|
|
.OrderBy(f => f, StringComparer.Ordinal);
|
|
foreach (var file in files)
|
|
{
|
|
LoadFile(mod, file);
|
|
}
|
|
}
|
|
|
|
ApplyPatches();
|
|
|
|
foreach (var entry in _byKey.Values)
|
|
{
|
|
Resolve(entry);
|
|
}
|
|
}
|
|
|
|
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>; throws when missing.</summary>
|
|
public T Get<T>(string defName)
|
|
where T : Def =>
|
|
TryGet<T>(defName, out var def)
|
|
? def
|
|
: throw new KeyNotFoundException($"No {typeof(T).Name} def named '{defName}'.");
|
|
|
|
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>, or false.</summary>
|
|
public bool TryGet<T>(string defName, out T def)
|
|
where T : Def
|
|
{
|
|
if (Entry<T>().Resolved.TryGetValue(defName, out var found))
|
|
{
|
|
def = (T)found;
|
|
return true;
|
|
}
|
|
|
|
def = null!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>All resolved defs of type <typeparamref name="T"/>, sorted by def name (deterministic).</summary>
|
|
public IReadOnlyList<T> All<T>()
|
|
where T : Def => Entry<T>().Resolved.Values.Cast<T>().ToList();
|
|
|
|
/// <summary>Registered def-type keys, sorted.</summary>
|
|
public IReadOnlyList<string> TypeKeys =>
|
|
_byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
|
|
|
|
/// <summary>Resolved def names of the given type key, sorted; empty for unknown keys.</summary>
|
|
public IReadOnlyList<string> NamesOf(string typeKey) =>
|
|
_byKey.TryGetValue(typeKey, out var entry) ? entry.Resolved.Keys.ToList() : [];
|
|
|
|
private TypeEntry Entry<T>()
|
|
where T : Def =>
|
|
_byType.TryGetValue(typeof(T), out var entry)
|
|
? entry
|
|
: throw new InvalidOperationException($"Def type {typeof(T).Name} is not registered.");
|
|
|
|
private void LoadFile(Mod mod, string file)
|
|
{
|
|
JsonNode root;
|
|
try
|
|
{
|
|
root =
|
|
JsonNode.Parse(File.ReadAllText(file), documentOptions: DocumentOptions)
|
|
?? throw new InvalidDataException("file is empty");
|
|
}
|
|
catch (JsonException exception)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Invalid def file '{file}' (mod '{mod.Id}'): {exception.Message}",
|
|
exception
|
|
);
|
|
}
|
|
|
|
var typeKey =
|
|
root["type"]?.GetValue<string>()
|
|
?? throw new InvalidDataException(
|
|
$"Def file '{file}' (mod '{mod.Id}') has no \"type\" field."
|
|
);
|
|
if (string.Equals(typeKey, PatchTypeKey, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
LoadPatches(mod, file, root);
|
|
return;
|
|
}
|
|
|
|
if (!_byKey.TryGetValue(typeKey, out var entry))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Def file '{file}' (mod '{mod.Id}') uses unknown def type '{typeKey}'; "
|
|
+ $"registered: {string.Join(", ", TypeKeys)}."
|
|
);
|
|
}
|
|
|
|
if (root["defs"] is not JsonArray defs)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Def file '{file}' (mod '{mod.Id}') has no \"defs\" array."
|
|
);
|
|
}
|
|
|
|
foreach (var node in defs)
|
|
{
|
|
if (node is not JsonObject def)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Def file '{file}' (mod '{mod.Id}') contains a non-object def entry."
|
|
);
|
|
}
|
|
|
|
var defName = def["defName"]?.GetValue<string>();
|
|
if (string.IsNullOrWhiteSpace(defName))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"A def in '{file}' (mod '{mod.Id}') has no \"defName\"."
|
|
);
|
|
}
|
|
|
|
entry.Raw[defName] = def; // поздний мод/файл полностью заменяет одноимённый деф
|
|
}
|
|
}
|
|
|
|
// Парсит файл-патч: операции { defType, match (регэксп по defName), set: {поля} }.
|
|
private void LoadPatches(Mod mod, string file, JsonNode root)
|
|
{
|
|
if (root["patches"] is not JsonArray patches)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Patch file '{file}' (mod '{mod.Id}') has no \"patches\" array."
|
|
);
|
|
}
|
|
|
|
foreach (var node in patches)
|
|
{
|
|
if (node is not JsonObject patch)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Patch file '{file}' (mod '{mod.Id}') contains a non-object patch."
|
|
);
|
|
}
|
|
|
|
var defType =
|
|
patch["defType"]?.GetValue<string>()
|
|
?? throw new InvalidDataException($"A patch in '{file}' has no \"defType\".");
|
|
var match =
|
|
patch["match"]?.GetValue<string>()
|
|
?? throw new InvalidDataException($"A patch in '{file}' has no \"match\".");
|
|
if (patch["set"] is not JsonObject set)
|
|
{
|
|
throw new InvalidDataException($"A patch in '{file}' has no \"set\" object.");
|
|
}
|
|
|
|
Regex regex;
|
|
try
|
|
{
|
|
regex = new Regex(match, RegexOptions.CultureInvariant);
|
|
}
|
|
catch (ArgumentException error)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Patch in '{file}' has invalid regex '{match}': {error.Message}"
|
|
);
|
|
}
|
|
|
|
_patches.Add(new PatchRule(defType, regex, (JsonObject)set.DeepClone()));
|
|
}
|
|
}
|
|
|
|
// Применяет патчи к сырым дефам (до резолва), в порядке загрузки: каждому дефу нужного типа,
|
|
// чьё имя подходит под регэксп, проставляются поля set. Поля наследуются детьми как обычно.
|
|
private void ApplyPatches()
|
|
{
|
|
foreach (var patch in _patches)
|
|
{
|
|
if (!_byKey.TryGetValue(patch.DefType, out var entry))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"A patch targets unknown def type '{patch.DefType}'."
|
|
);
|
|
}
|
|
|
|
foreach (var raw in entry.Raw)
|
|
{
|
|
if (!patch.Match.IsMatch(raw.Key))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var (key, value) in patch.Set)
|
|
{
|
|
raw.Value[key] = value?.DeepClone();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Resolve(TypeEntry entry)
|
|
{
|
|
_validators.TryGetValue(entry.Key, out var rules);
|
|
foreach (var defName in entry.Raw.Keys.Order(StringComparer.Ordinal))
|
|
{
|
|
var merged = MergeChain(entry, defName, []);
|
|
if (merged["abstract"]?.GetValue<bool>() == true)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (rules is not null)
|
|
{
|
|
Validate(entry.Key, defName, merged, rules);
|
|
}
|
|
|
|
var def =
|
|
(Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
|
|
?? throw new InvalidDataException(
|
|
$"Def '{defName}' ({entry.Key}) deserialized to null."
|
|
);
|
|
entry.Resolved[defName] = def;
|
|
}
|
|
}
|
|
|
|
private static void Validate(
|
|
string typeKey,
|
|
string defName,
|
|
JsonObject merged,
|
|
List<ValidationRule> rules
|
|
)
|
|
{
|
|
foreach (var rule in rules)
|
|
{
|
|
if (
|
|
merged[rule.Field] is JsonValue value
|
|
&& value.TryGetValue<string>(out var text)
|
|
&& !rule.Pattern.IsMatch(text)
|
|
)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Def '{defName}' ({typeKey}) field '{rule.Field}'=\"{text}\" violates {rule.Description}."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static JsonObject MergeChain(TypeEntry entry, string defName, HashSet<string> seen)
|
|
{
|
|
if (!seen.Add(defName))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Cyclic def inheritance involving '{defName}' ({entry.Key})."
|
|
);
|
|
}
|
|
|
|
if (!entry.Raw.TryGetValue(defName, out var node))
|
|
{
|
|
throw new InvalidDataException($"Unknown parent def '{defName}' ({entry.Key}).");
|
|
}
|
|
|
|
var parentName = node["parent"]?.GetValue<string>();
|
|
if (parentName is null)
|
|
{
|
|
return (JsonObject)node.DeepClone();
|
|
}
|
|
|
|
var merged = MergeChain(entry, parentName, seen);
|
|
merged.Remove("abstract"); // абстрактность не наследуется
|
|
merged.Remove("defName");
|
|
foreach (var (key, value) in node)
|
|
{
|
|
merged[key] = value?.DeepClone();
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
}
|