Defines CharacterPreset and CharacterLibrary for a character editor. CharacterPreset holds metadata and CharacterAppearanceData, serializes to/from JSON, validates and deep-copies via System.Text.Json. CharacterLibrary manages on-disk preset files under character_editor/presets, loading, saving with backups and pending writes, deleting and importing presets.
using System;
using System.Text.Json;
namespace CharacterEditor;
/// <summary>Portable envelope. Identity belongs to a saved character, never its display name.</summary>
public sealed class CharacterPreset
{
public string Format { get; set; } = "character-editor-preset";
public int Version { get; set; } = 1;
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Name { get; set; } = "New character";
public bool Favorite { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
public CharacterAppearanceData Appearance { get; set; } = new();
public CharacterPreset DeepCopy() => JsonSerializer.Deserialize<CharacterPreset>(ToJson());
public string ToJson() => JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
public string Validate()
{
if (Format != "character-editor-preset" || Version != 1)
return "Unsupported character preset format or version.";
if (!Guid.TryParseExact(Id, "N", out _))
return "Invalid character identity.";
if (string.IsNullOrWhiteSpace(Name) || Name.Length > 40)
return "Give your character a name of 1–40 characters.";
return CharacterAppearanceData.Validate(Appearance);
}
public static CharacterPreset FromJson(string json)
{
if (string.IsNullOrWhiteSpace(json) || json.Length > 131072)
throw new InvalidOperationException("Paste a preset smaller than 128 KB.");
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
throw new InvalidOperationException("A preset must be a JSON object.");
// Raw Fate Pending v1 appearance data can be imported without its campaign envelope.
var preset = doc.RootElement.TryGetProperty("Format", out _) ? JsonSerializer.Deserialize<CharacterPreset>(json) : doc.RootElement.TryGetProperty("SkinPath", out _) ? new CharacterPreset
{
Name = "Imported character",
Appearance = JsonSerializer.Deserialize<CharacterAppearanceData>(json)
}
: null;
if (preset is null)
throw new InvalidOperationException("This is not a character preset or appearance.");
if (preset.Validate()is string error)
throw new InvalidOperationException(error);
return preset;
}
}
/// <summary>Per-game storage; portable JSON is the explicit bridge across game sandboxes.</summary>
public sealed class CharacterLibrary
{
public const string Directory = "character_editor/presets";
public List<CharacterPreset> Items { get; } = new();
public List<string> Warnings { get; } = new();
public int Revision { get; private set; }
public void Reload()
{
Items.Clear();
Warnings.Clear();
FileSystem.Data.CreateDirectory(Directory);
foreach (var file in FileSystem.Data.FindFile(Directory, "*.json"))
{
var path = $"{Directory}/{file}";
try
{
var value = CharacterPreset.FromJson(FileSystem.Data.ReadAllText(path));
if (file != value.Id + ".json")
throw new InvalidOperationException("Preset identity does not match its file.");
Items.Add(value);
}
catch (Exception e)
{
try
{
var backup = CharacterPreset.FromJson(FileSystem.Data.ReadAllText(path + ".bak"));
if (file != backup.Id + ".json")
throw new InvalidOperationException();
Items.Add(backup);
Warnings.Add($"Recovered {backup.Name} from its previous save.");
}
catch
{
Warnings.Add($"Skipped {file}: {e.Message}");
}
}
}
Revision++;
}
public void Save(CharacterPreset value)
{
if (value.Validate()is string error)
throw new InvalidOperationException(error);
var saved = value.DeepCopy();
saved.Name = saved.Name.Trim();
saved.UpdatedUtc = DateTime.UtcNow;
var path = $"{Directory}/{saved.Id}.json";
FileSystem.Data.CreateDirectory(Directory);
if (FileSystem.Data.FileExists(path))
{
var old = FileSystem.Data.ReadAllText(path);
try
{
CharacterPreset.FromJson(old);
FileSystem.Data.WriteAllText(path + ".bak", old);
}
catch (InvalidOperationException)
{
}
catch (JsonException)
{
}
}
var json = saved.ToJson();
FileSystem.Data.WriteAllText(path + ".pending", json);
CharacterPreset.FromJson(FileSystem.Data.ReadAllText(path + ".pending"));
FileSystem.Data.WriteAllText(path, json);
if (CharacterPreset.FromJson(FileSystem.Data.ReadAllText(path)).ToJson() != json)
throw new InvalidOperationException("Could not verify the saved character.");
FileSystem.Data.DeleteFile(path + ".pending");
Items.RemoveAll(x => x.Id == saved.Id);
Items.Add(saved);
Revision++;
}
public void Delete(string id)
{
if (!Guid.TryParseExact(id, "N", out _))
throw new InvalidOperationException("Invalid character identity.");
foreach (var suffix in new[]
{
".json",
".json.bak",
".json.pending"
}
)
{
var path = $"{Directory}/{id}{suffix}";
if (FileSystem.Data.FileExists(path))
FileSystem.Data.DeleteFile(path);
}
Items.RemoveAll(x => x.Id == id);
Revision++;
}
public CharacterPreset Import(string json)
{
var p = CharacterPreset.FromJson(json);
p.Id = Guid.NewGuid().ToString("N");
Save(p);
return p;
}
}