Code/CharacterKit/CharacterPreset.cs

Character preset and library classes. CharacterPreset is a portable JSON envelope for saved character data with serialization, validation and import logic. CharacterLibrary manages on-disk storage of presets under character_editor/presets, loading, saving with backups and pending writes, deleting and importing presets.

File AccessHttp Calls
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;
	}
}