using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace MrGameEng.Mods;
///
/// Database of data definitions loaded from mod JSON. Each file under a mod's
/// Defs/ folder is an envelope { "type": "<key>", "defs": [ … ] }; the
/// game registers the CLR type for every key before . Defs from later
/// mods replace same-named defs of earlier mods; parent chains are merged
/// field-by-field (own fields win, nested objects are replaced whole); defs marked
/// abstract serve only as parents.
///
public sealed class DefDatabase
{
private sealed class TypeEntry
{
public required string Key;
public required Type ClrType;
public readonly Dictionary Raw = new(StringComparer.Ordinal);
public readonly SortedDictionary Resolved = new(StringComparer.Ordinal);
}
private readonly Dictionary _byKey = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _byType = [];
/// Reserved def-file "type" that carries content patches rather than defs.
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 _patches = [];
private readonly Dictionary> _validators = new(
StringComparer.OrdinalIgnoreCase
);
private static readonly JsonDocumentOptions DocumentOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// Registers the CLR type behind a def-type key (the "type" field of def files).
public void RegisterType(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);
}
///
/// Registers a load-time validation: the string of every resolved def of
/// type must match (a regex), or
/// throws. Non-string or absent fields are skipped. Use it to enforce naming
/// conventions (e.g. gene ids start with Gene) or key/format rules across a mod's content.
///
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}/"));
}
///
/// Loads every Defs/**/*.json of (in load order) and
/// resolves inheritance. Call once after registering all def types.
///
public void Load(IReadOnlyList 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);
}
}
/// Returns the def of type named ; throws when missing.
public T Get(string defName)
where T : Def =>
TryGet(defName, out var def)
? def
: throw new KeyNotFoundException($"No {typeof(T).Name} def named '{defName}'.");
/// Returns the def of type named , or false.
public bool TryGet(string defName, out T def)
where T : Def
{
if (Entry().Resolved.TryGetValue(defName, out var found))
{
def = (T)found;
return true;
}
def = null!;
return false;
}
/// All resolved defs of type , sorted by def name (deterministic).
public IReadOnlyList All()
where T : Def => Entry().Resolved.Values.Cast().ToList();
/// Registered def-type keys, sorted.
public IReadOnlyList TypeKeys =>
_byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
/// Resolved def names of the given type key, sorted; empty for unknown keys.
public IReadOnlyList NamesOf(string typeKey) =>
_byKey.TryGetValue(typeKey, out var entry) ? entry.Resolved.Keys.ToList() : [];
private TypeEntry Entry()
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()
?? 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();
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()
?? throw new InvalidDataException($"A patch in '{file}' has no \"defType\".");
var match =
patch["match"]?.GetValue()
?? 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() == 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 rules
)
{
foreach (var rule in rules)
{
if (
merged[rule.Field] is JsonValue value
&& value.TryGetValue(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 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();
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;
}
}