using System; using System.Collections.Generic; using System.Linq; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; namespace Cthangover.Tools.EntityEditor { /// /// The entities of one kind ("Kind": "item", "character"…) in one /// mod, as an editor changes them, free of any UI. /// /// Files are kept as JSON trees, not as typed DTOs: a form changes only the /// properties it knows, and everything else — fields of other mods' C# /// (heal, verb, Charset), loot tables, the property /// order — survives a save untouched. A file is written only when its text /// differs from what was loaded or last saved, so opening and saving does not /// reformat files nobody changed. New entities go to , /// created on the first save when the mod has no such file. /// /// Localized texts are part of the document too (): the /// form shows a name in every language of the mod, and an undo step restores /// texts and data together. Undo works on whole states, as in the map editor. /// public class EntityDocument { private const int HistoryLimit = 200; private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; private readonly Dictionary files = new(StringComparer.Ordinal); private readonly Dictionary savedText = new(StringComparer.Ordinal); private readonly List records = new(); private readonly List undo = new(); private readonly List redo = new(); private Dictionary texts = new(StringComparer.Ordinal); private Dictionary savedTexts = new(StringComparer.Ordinal); /// Entity kind of the document. public string Kind { get; } /// File new entities are added to. public string NewFile { get; } /// The entities in file order (files sorted by path). public IReadOnlyList Records => records; /// True when there is a step to undo. public bool CanUndo => undo.Count > 0; /// True when there is a step to redo. public bool CanRedo => redo.Count > 0; private EntityDocument(string kind, string newFile) { Kind = kind; NewFile = newFile; } /// /// Builds the document from the mod's JSON files (path, text). Files of /// another kind, without a kind or with broken JSON are skipped; their /// paths are returned in only when they declare /// this kind but cannot be read, so the window can name them. /// public static EntityDocument Load(string kind, string newFile, IEnumerable<(string Path, string Text)> sources, out List skipped) { var document = new EntityDocument(kind, newFile); skipped = new List(); foreach (var (path, text) in sources.OrderBy(s => s.Path, StringComparer.Ordinal)) { JsonObject root; try { root = JsonNode.Parse(text ?? string.Empty) as JsonObject; } catch (JsonException) { if (text != null && text.Contains($"\"{kind}\"")) skipped.Add(path); continue; } if (root == null || !string.Equals(root["Kind"]?.ToString(), kind, StringComparison.OrdinalIgnoreCase)) continue; if (root["Items"] is not JsonArray) root["Items"] = new JsonArray(); document.files[path] = root; document.savedText[path] = Serialize(root); } document.Rebuild(); return document; } /// Paths of the document's files. public IEnumerable Files => files.Keys; /// Adds an entity to (the file is created when missing) and returns it. public EntityRecord Add(JsonObject entity) { if (!files.TryGetValue(NewFile, out var root)) { root = new JsonObject { ["Kind"] = Kind, ["Items"] = new JsonArray() }; files[NewFile] = root; } ((JsonArray)root["Items"]).Add(entity); Rebuild(); return records.First(r => r.Data == entity); } /// A copy of with id , placed right after it in its file. public EntityRecord Duplicate(EntityRecord record, string newId) { var copy = (JsonObject)record.Data.DeepClone(); copy["Id"] = newId; var items = (JsonArray)files[record.File]["Items"]; items.Insert(items.IndexOf(record.Data) + 1, copy); Rebuild(); return records.First(r => r.Data == copy); } /// Removes an entity from its file (the file stays, maybe with no items). public void Remove(EntityRecord record) { ((JsonArray)files[record.File]["Items"]).Remove(record.Data); Rebuild(); } /// when no entity has it, else wanted_2, wanted_3… public string FreeId(string wanted) { var id = wanted; for (var number = 2; records.Any(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); number++) id = wanted + "_" + number; return id; } /// The record with this id, or null. public EntityRecord Find(string id) => records.FirstOrDefault(r => r.Id == id); /// A localized text the document holds (see ), or null when it was never set or loaded. public string GetText(string language, string key) => texts.TryGetValue(TextKey(language, key), out var value) ? value : null; /// Remembers the text the mod's files have now (loading, not an edit). public void LoadText(string language, string key, string value) { var textKey = TextKey(language, key); texts[textKey] = value; savedTexts[textKey] = value; } /// Changes a localized text; it is written on save. public void SetText(string language, string key, string value) => texts[TextKey(language, key)] = value ?? string.Empty; /// Remembers the current state as one undo step and forgets the redo branch. public void BeginChange() { undo.Add(Snapshot()); if (undo.Count > HistoryLimit) undo.RemoveAt(0); redo.Clear(); } /// Steps back; false when there is nothing to undo. public bool Undo() => Swap(undo, redo); /// Steps forward again; false when there is nothing to redo. public bool Redo() => Swap(redo, undo); /// Files whose text changed since loading or the last . public List<(string Path, string Text)> ChangedFiles() => files.Select(f => (f.Key, Serialize(f.Value))) .Where(f => !savedText.TryGetValue(f.Key, out var saved) || saved != f.Item2) .ToList(); /// Texts changed since loading or the last : language, key, value. public List<(string Language, string Key, string Value)> ChangedTexts() => texts.Where(t => !savedTexts.TryGetValue(t.Key, out var saved) || saved != t.Value) .Select(t => (t.Key.Substring(0, t.Key.IndexOf('\n')), t.Key.Substring(t.Key.IndexOf('\n') + 1), t.Value)) .ToList(); /// The current files and texts are what is on disk now. public void MarkSaved() { foreach (var (path, text) in ChangedFiles()) savedText[path] = text; savedTexts = new Dictionary(texts, StringComparer.Ordinal); } /// Problems that would break the game's data, as locale keys with an argument; empty when it can be saved. public List<(string Key, string Argument)> Validate() { var problems = new List<(string, string)>(); foreach (var record in records.Where(r => string.IsNullOrWhiteSpace(r.Id))) problems.Add(("tools/entity_editor/problem_id", record.File)); foreach (var group in records.Where(r => !string.IsNullOrWhiteSpace(r.Id)).GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase)) { if (group.Count() > 1) problems.Add(("tools/entity_editor/problem_duplicate", group.Key)); } return problems; } /// A file's text as the document writes it: two-space indented, letters unescaped, LF line breaks (the writer would use CRLF on Windows), a final one. public static string Serialize(JsonObject root) => root.ToJsonString(WriteOptions).Replace("\r\n", "\n") + "\n"; private static string TextKey(string language, string key) => language + "\n" + key; private void Rebuild() { records.Clear(); foreach (var file in files.OrderBy(f => f.Key, StringComparer.Ordinal)) { foreach (var item in (JsonArray)file.Value["Items"]) { if (item is JsonObject entity) records.Add(new EntityRecord(file.Key, entity)); } } } private string Snapshot() { var state = new JsonObject { ["files"] = new JsonObject(files.Select(f => new KeyValuePair(f.Key, f.Value.DeepClone()))), ["texts"] = new JsonObject(texts.Select(t => new KeyValuePair(t.Key, t.Value))) }; return state.ToJsonString(); } private bool Swap(List from, List to) { if (from.Count == 0) return false; to.Add(Snapshot()); var state = JsonNode.Parse(from[^1]).AsObject(); from.RemoveAt(from.Count - 1); files.Clear(); foreach (var file in state["files"].AsObject()) files[file.Key] = file.Value.DeepClone().AsObject(); texts = state["texts"].AsObject().ToDictionary(t => t.Key, t => t.Value?.ToString() ?? string.Empty, StringComparer.Ordinal); Rebuild(); return true; } } }