feat(progression): json stores on a real filesystem (slice 8)
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.
This commit is contained in:
0
progression.adapters/.gdignore
Normal file
0
progression.adapters/.gdignore
Normal file
77
progression.adapters/JsonFiles.cs
Normal file
77
progression.adapters/JsonFiles.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
30
progression.adapters/JsonPlaythroughStore.cs
Normal file
30
progression.adapters/JsonPlaythroughStore.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace Mr.BrickAdventures.Progression.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps each Playthrough in its own JSON file inside a directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Takes a directory path rather than using Godot's FileAccess. Godot resolves <c>user://</c> to an
|
||||
/// absolute path with <c>ProjectSettings.GlobalizePath</c> and passes it in, so this whole class has
|
||||
/// no engine dependency and can be tested against a real temp directory.
|
||||
/// </remarks>
|
||||
public sealed class JsonPlaythroughStore : IPlaythroughStore
|
||||
{
|
||||
/// <summary>Bump only alongside a migration for the version being replaced.</summary>
|
||||
private const int CurrentVersion = 1;
|
||||
|
||||
private readonly string _directory;
|
||||
|
||||
public JsonPlaythroughStore(string directory) => _directory = directory;
|
||||
|
||||
/// <summary>One file per slot, so saving a run never rewrites the Profile or another slot.</summary>
|
||||
private string PathFor(PlaythroughId id) =>
|
||||
Path.Combine(_directory, $"playthrough_{id.Value:N}.json");
|
||||
|
||||
public void Save(Playthrough playthrough) =>
|
||||
JsonFiles.WriteAtomic(PathFor(playthrough.Id), CurrentVersion, playthrough.Export());
|
||||
|
||||
public LoadResult<Playthrough> Load(PlaythroughId id) =>
|
||||
JsonFiles.Read<PlaythroughSnapshot, Playthrough>(
|
||||
PathFor(id), CurrentVersion, Playthrough.Restore);
|
||||
}
|
||||
20
progression.adapters/JsonProfileStore.cs
Normal file
20
progression.adapters/JsonProfileStore.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Mr.BrickAdventures.Progression.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the Profile in its own JSON file, beside the Playthrough files but written independently.
|
||||
/// </summary>
|
||||
public sealed class JsonProfileStore : IProfileStore
|
||||
{
|
||||
/// <summary>Versioned separately from Playthroughs — the two shapes drift at their own pace.</summary>
|
||||
private const int CurrentVersion = 1;
|
||||
|
||||
private readonly string _path;
|
||||
|
||||
public JsonProfileStore(string directory) => _path = Path.Combine(directory, "profile.json");
|
||||
|
||||
public void Save(Profile profile) =>
|
||||
JsonFiles.WriteAtomic(_path, CurrentVersion, profile.Export());
|
||||
|
||||
public LoadResult<Profile> Load() =>
|
||||
JsonFiles.Read<ProfileSnapshot, Profile>(_path, CurrentVersion, Profile.Restore);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\progression\Mr.BrickAdventures.Progression.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Mr.BrickAdventures.Progression.Adapters</RootNamespace>
|
||||
|
||||
<!-- Required on assemblies referenced by a Godot project. -->
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Deliberately no engine reference. These adapters take a plain directory path; Godot resolves
|
||||
user:// to an absolute path and passes it in, which is what keeps them testable. -->
|
||||
|
||||
|
||||
</Project>
|
||||
10
progression.adapters/SaveEnvelope.cs
Normal file
10
progression.adapters/SaveEnvelope.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Mr.BrickAdventures.Progression.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// What actually goes in a save file: a version alongside the snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The version lives here rather than in the snapshot record so the domain only ever knows the
|
||||
/// current shape. Migration is entirely this layer's problem.
|
||||
/// </remarks>
|
||||
internal sealed record SaveEnvelope<T>(int Version, T Data);
|
||||
Reference in New Issue
Block a user