new progression.adapters project, no engine reference. Stores take a directory path; Godot resolves user:// with GlobalizePath and passes it in, which is what makes them testable at all - originally I'd written this slice off as untestable. version lives in a file envelope, not the snapshot, so the domain only knows the current shape. A newer-build save reports Corrupt with a reason instead of masquerading as "no save" and getting overwritten. atomic write via temp + rename. One file per aggregate: saving a run leaves the profile file byte-identical, asserted. guard rail fixed - it was matching prose in doc comments, now anchored to using-directives plus a project.assets.json check. 63 tests.
78 lines
2.8 KiB
C#
78 lines
2.8 KiB
C#
using System.Text.Json;
|
|
|
|
namespace Mr.BrickAdventures.Progression.Adapters;
|
|
|
|
/// <summary>
|
|
/// Reading and writing versioned save files. Shared by the Playthrough and Profile stores, which
|
|
/// differ only in what they hold and where they live.
|
|
/// </summary>
|
|
internal static class JsonFiles
|
|
{
|
|
internal static readonly JsonSerializerOptions Options = new()
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Writes to a temporary file and then renames it over the destination. A crash mid-write leaves
|
|
/// the previous file intact rather than a half-written one — which matters more once there are
|
|
/// several slots to lose.
|
|
/// </summary>
|
|
internal static void WriteAtomic<T>(string path, int version, T data)
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
|
|
var temp = path + ".tmp";
|
|
File.WriteAllText(temp, JsonSerializer.Serialize(new SaveEnvelope<T>(version, data), Options));
|
|
File.Move(temp, path, overwrite: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a save file. Absent when there is no file; Corrupt when there is one but it cannot be
|
|
/// turned into <typeparamref name="TAggregate"/> — unreadable JSON, an unknown version, or a
|
|
/// snapshot the domain rejects. Never conflates the two: that conflation is what let a version
|
|
/// bump silently overwrite a player's save.
|
|
/// </summary>
|
|
internal static LoadResult<TAggregate> Read<TSnapshot, TAggregate>(
|
|
string path,
|
|
int currentVersion,
|
|
Func<TSnapshot, TAggregate> restore)
|
|
where TSnapshot : class
|
|
where TAggregate : class
|
|
{
|
|
if (!File.Exists(path)) return LoadResult<TAggregate>.Absent();
|
|
|
|
SaveEnvelope<TSnapshot>? envelope;
|
|
try
|
|
{
|
|
envelope = JsonSerializer.Deserialize<SaveEnvelope<TSnapshot>>(File.ReadAllText(path), Options);
|
|
}
|
|
catch (JsonException e)
|
|
{
|
|
return LoadResult<TAggregate>.Corrupt($"unreadable json: {e.Message}");
|
|
}
|
|
|
|
if (envelope?.Data is null)
|
|
return LoadResult<TAggregate>.Corrupt("file held no save data");
|
|
|
|
if (envelope.Version > currentVersion)
|
|
return LoadResult<TAggregate>.Corrupt(
|
|
$"saved by a newer build (version {envelope.Version}, this build reads {currentVersion})");
|
|
|
|
if (envelope.Version < currentVersion)
|
|
return LoadResult<TAggregate>.Corrupt(
|
|
$"version {envelope.Version} has no migration to {currentVersion}");
|
|
|
|
try
|
|
{
|
|
return LoadResult<TAggregate>.Loaded(restore(envelope.Data));
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return LoadResult<TAggregate>.Corrupt($"snapshot rejected by the domain: {e.Message}");
|
|
}
|
|
}
|
|
}
|