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:
2026-07-28 19:35:43 +02:00
parent f991bb99fc
commit 7a69292066
13 changed files with 385 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
<Project Sdk="Godot.NET.Sdk/4.6.2"> <Project Sdk="Godot.NET.Sdk/4.7.1">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading> <EnableDynamicLoading>true</EnableDynamicLoading>
@@ -15,9 +15,11 @@
<ItemGroup> <ItemGroup>
<Compile Remove="progression\**" /> <Compile Remove="progression\**" />
<Compile Remove="progression.tests\**" /> <Compile Remove="progression.tests\**" />
<Compile Remove="progression.adapters\**" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="progression\Mr.BrickAdventures.Progression.csproj" /> <ProjectReference Include="progression\Mr.BrickAdventures.Progression.csproj" />
<ProjectReference Include="progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -11,6 +11,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression.tests", "progre
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Tests", "progression.tests\Mr.BrickAdventures.Progression.Tests.csproj", "{A4660883-E42A-4D04-99A3-770F36E9D861}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Tests", "progression.tests\Mr.BrickAdventures.Progression.Tests.csproj", "{A4660883-E42A-4D04-99A3-770F36E9D861}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression.adapters", "progression.adapters", "{9D5C8041-6C54-0A01-82AE-FC7E0DE079DA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Adapters", "progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj", "{045D9BF4-8E63-4F14-84F4-F8C55073244C}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -99,6 +103,30 @@ Global
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x64.Build.0 = Release|Any CPU {A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x64.Build.0 = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.ActiveCfg = Release|Any CPU {A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.ActiveCfg = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.Build.0 = Release|Any CPU {A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.Build.0 = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x64.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x64.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x86.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x86.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x64.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x86.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x64.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x86.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|Any CPU.Build.0 = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x64.ActiveCfg = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x64.Build.0 = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x86.ActiveCfg = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -106,5 +134,6 @@ Global
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{4F115C37-24F6-4933-9086-ED5925EE5106} = {73CE8E92-AE0D-34EB-F3CD-A6392CC7A1AA} {4F115C37-24F6-4933-9086-ED5925EE5106} = {73CE8E92-AE0D-34EB-F3CD-A6392CC7A1AA}
{A4660883-E42A-4D04-99A3-770F36E9D861} = {B756B2BA-9E41-468E-DBBE-5BA8CC0A1E41} {A4660883-E42A-4D04-99A3-770F36E9D861} = {B756B2BA-9E41-468E-DBBE-5BA8CC0A1E41}
{045D9BF4-8E63-4F14-84F4-F8C55073244C} = {9D5C8041-6C54-0A01-82AE-FC7E0DE079DA}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -40,11 +40,15 @@ No tests. Pure setup.
**Guard rail for the whole phase:** **Guard rail for the whole phase:**
```sh ```sh
grep -rn "using Godot" progression progression.tests --include=*.cs # must print nothing # must print nothing — anchored to real using-directives
grep -rnE "^\s*(global\s+)?using\s+Godot" progression progression.adapters progression.tests --include=*.cs
# stronger, and cannot be fooled by wording: no project may resolve the engine assembly at all
grep -c GodotSharp progression/obj/project.assets.json progression.adapters/obj/project.assets.json
``` ```
That single check is what ADR-0004 buys you. Scope it to `--include=*.cs` — an unscoped `grep -r` also matches That check is what ADR-0004 buys you. Match using-directives **anchored to the start of a line**: a bare
prose in the `.csproj` comments and reports a false violation. `grep -r "using Godot"` also hits `.csproj` comments and doc comments that legitimately mention the engine in prose,
and reports a false violation. The `project.assets.json` check is the one that cannot be fooled by wording.
--- ---

View File

View 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}");
}
}
}

View 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);
}

View 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);
}

View File

@@ -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>

View 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);

View File

@@ -0,0 +1,121 @@
using Mr.BrickAdventures.Progression.Adapters;
namespace Mr.BrickAdventures.Progression.Tests;
/// <summary>
/// These run against a real directory on disk. The store takes a path rather than using Godot's
/// FileAccess, which is the whole reason this is testable at all.
/// </summary>
public sealed class JsonPlaythroughStoreTests : IDisposable
{
private readonly string _directory =
Path.Combine(Path.GetTempPath(), "ppc-progression-" + Guid.NewGuid().ToString("N"));
private static readonly LevelId First = LevelId.Of("village_1");
private static readonly LevelId Second = LevelId.Of("village_2");
private static readonly PlaythroughId Id =
PlaythroughId.Of(new Guid("55555555-5555-5555-5555-555555555555"));
private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second });
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
[Fact]
public void A_playthrough_survives_a_round_trip_through_a_real_file()
{
var store = new JsonPlaythroughStore(_directory);
var campaign = TwoLevels();
var playthrough = Playthrough.StartNew(Id, campaign, Lives.Of(3));
var attempt = new Attempt(First);
attempt.RecordCoinsFound(Coins.Of(50));
attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.Of(2), Coins.Of(30));
playthrough.SettleCompletion(attempt, campaign);
store.Save(playthrough);
var result = store.Load(Id);
Assert.Equal(LoadOutcome.Loaded, result.Outcome);
Assert.Equal(Coins.Of(20), result.Value!.Bank);
Assert.Equal(Second, result.Value.CurrentLevel);
Assert.Equal(SkillLevel.Of(2), result.Value.OwnedSkills[SkillId.Of("brick_throw")]);
Assert.True(result.Value.HasCleared(First));
}
[Fact]
public void No_file_means_absent()
{
Assert.Equal(LoadOutcome.Absent, new JsonPlaythroughStore(_directory).Load(Id).Outcome);
}
[Fact]
public void Unreadable_json_is_corrupt_not_absent()
{
var store = new JsonPlaythroughStore(_directory);
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
File.WriteAllText(Directory.GetFiles(_directory, "*.json").Single(), "{ this is not json");
var result = store.Load(Id);
Assert.Equal(LoadOutcome.Corrupt, result.Outcome);
Assert.NotNull(result.Problem);
}
[Fact]
public void A_save_from_a_newer_build_is_corrupt_rather_than_treated_as_no_save()
{
// The bug this replaces, exactly: SaveSystem compared versions with != and returned false,
// ContinueGame read false as "no save", called StartNewGame, and overwrote the file. Here the
// caller can tell the difference and say so instead.
var store = new JsonPlaythroughStore(_directory);
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
var file = Directory.GetFiles(_directory, "*.json").Single();
File.WriteAllText(file, File.ReadAllText(file).Replace("\"version\": 1", "\"version\": 99"));
var result = store.Load(Id);
Assert.Equal(LoadOutcome.Corrupt, result.Outcome);
Assert.Contains("newer build", result.Problem);
}
[Fact]
public void A_snapshot_the_domain_rejects_is_corrupt()
{
var store = new JsonPlaythroughStore(_directory);
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
var file = Directory.GetFiles(_directory, "*.json").Single();
File.WriteAllText(file, File.ReadAllText(file).Replace("\"lives\": 3", "\"lives\": -1"));
Assert.Equal(LoadOutcome.Corrupt, store.Load(Id).Outcome);
}
[Fact]
public void Saving_leaves_no_temporary_file_behind()
{
var store = new JsonPlaythroughStore(_directory);
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(2)));
Assert.Empty(Directory.GetFiles(_directory, "*.tmp"));
Assert.Single(Directory.GetFiles(_directory, "*.json"));
Assert.Equal(2, store.Load(Id).Value!.Lives.Value);
}
[Fact]
public void Each_playthrough_gets_its_own_file_so_slots_do_not_disturb_each_other()
{
// The reason for a flat record per aggregate rather than one combined document.
var other = PlaythroughId.Of(new Guid("66666666-6666-6666-6666-666666666666"));
var store = new JsonPlaythroughStore(_directory);
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
store.Save(Playthrough.StartNew(other, TwoLevels(), Lives.Of(1)));
Assert.Equal(2, Directory.GetFiles(_directory, "*.json").Length);
Assert.Equal(3, store.Load(Id).Value!.Lives.Value);
Assert.Equal(1, store.Load(other).Value!.Lives.Value);
}
}

View File

@@ -0,0 +1,65 @@
using Mr.BrickAdventures.Progression.Adapters;
namespace Mr.BrickAdventures.Progression.Tests;
public sealed class JsonProfileStoreTests : IDisposable
{
private readonly string _directory =
Path.Combine(Path.GetTempPath(), "ppc-progression-" + Guid.NewGuid().ToString("N"));
private static readonly LevelId Level = LevelId.Of("village_1");
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
[Fact]
public void A_profile_survives_a_round_trip_through_a_real_file()
{
var store = new JsonProfileStore(_directory);
var profile = Profile.Empty();
profile.RecordTime(Level, LevelTime.Of(31.25));
profile.UnlockAchievement(AchievementId.Of("rescue_ten_kids"));
profile.Record(StatName.Deaths, 7);
store.Save(profile);
var result = store.Load();
Assert.Equal(LoadOutcome.Loaded, result.Outcome);
Assert.Equal(LevelTime.Of(31.25), result.Value!.BestTimeFor(Level));
Assert.True(result.Value.HasAchievement(AchievementId.Of("rescue_ten_kids")));
Assert.Equal(7, result.Value.ReadStat(StatName.Deaths));
}
[Fact]
public void On_a_first_run_there_is_no_profile_file_and_that_is_absent()
{
Assert.Equal(LoadOutcome.Absent, new JsonProfileStore(_directory).Load().Outcome);
}
[Fact]
public void Saving_a_playthrough_does_not_touch_the_profile_file()
{
// The point of one file per aggregate. A run being saved on every level completion must not
// rewrite achievements and best times, and a corrupt slot must not cost them.
var profiles = new JsonProfileStore(_directory);
var playthroughs = new JsonPlaythroughStore(_directory);
var profile = Profile.Empty();
profile.RecordTime(Level, LevelTime.Of(31.25));
profiles.Save(profile);
var profilePath = Directory.GetFiles(_directory, "profile*.json").Single();
var before = File.GetLastWriteTimeUtc(profilePath);
var contentBefore = File.ReadAllText(profilePath);
playthroughs.Save(Playthrough.StartNew(
PlaythroughId.Of(Guid.NewGuid()),
Campaign.Of(new[] { Level }),
Lives.Of(3)));
Assert.Equal(before, File.GetLastWriteTimeUtc(profilePath));
Assert.Equal(contentBefore, File.ReadAllText(profilePath));
Assert.Equal(2, Directory.GetFiles(_directory, "*.json").Length);
}
}

View File

@@ -12,6 +12,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\progression\Mr.BrickAdventures.Progression.csproj" /> <ProjectReference Include="..\progression\Mr.BrickAdventures.Progression.csproj" />
<ProjectReference Include="..\progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1 @@
uid://cxryfiktqs7s1