diff --git a/Mr. Brick Adventures.csproj b/Mr. Brick Adventures.csproj index 29aa0d5..bebb798 100644 --- a/Mr. Brick Adventures.csproj +++ b/Mr. Brick Adventures.csproj @@ -1,4 +1,4 @@ - + net8.0 true @@ -15,9 +15,11 @@ + + \ No newline at end of file diff --git a/Mr. Brick Adventures.sln b/Mr. Brick Adventures.sln index 02a242d..d42d47b 100644 --- a/Mr. Brick Adventures.sln +++ b/Mr. Brick Adventures.sln @@ -11,6 +11,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression.tests", "progre EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Tests", "progression.tests\Mr.BrickAdventures.Progression.Tests.csproj", "{A4660883-E42A-4D04-99A3-770F36E9D861}" 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 GlobalSection(SolutionConfigurationPlatforms) = preSolution 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|x86.ActiveCfg = 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 GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -106,5 +134,6 @@ Global GlobalSection(NestedProjects) = preSolution {4F115C37-24F6-4933-9086-ED5925EE5106} = {73CE8E92-AE0D-34EB-F3CD-A6392CC7A1AA} {A4660883-E42A-4D04-99A3-770F36E9D861} = {B756B2BA-9E41-468E-DBBE-5BA8CC0A1E41} + {045D9BF4-8E63-4F14-84F4-F8C55073244C} = {9D5C8041-6C54-0A01-82AE-FC7E0DE079DA} EndGlobalSection EndGlobal diff --git a/docs/plans/phase-a-progression-domain.md b/docs/plans/phase-a-progression-domain.md index ca6f429..7a1c78f 100644 --- a/docs/plans/phase-a-progression-domain.md +++ b/docs/plans/phase-a-progression-domain.md @@ -40,11 +40,15 @@ No tests. Pure setup. **Guard rail for the whole phase:** ```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 -prose in the `.csproj` comments and reports a false violation. +That check is what ADR-0004 buys you. Match using-directives **anchored to the start of a line**: a bare +`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. --- diff --git a/progression.adapters/.gdignore b/progression.adapters/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/progression.adapters/JsonFiles.cs b/progression.adapters/JsonFiles.cs new file mode 100644 index 0000000..419846b --- /dev/null +++ b/progression.adapters/JsonFiles.cs @@ -0,0 +1,77 @@ +using System.Text.Json; + +namespace Mr.BrickAdventures.Progression.Adapters; + +/// +/// Reading and writing versioned save files. Shared by the Playthrough and Profile stores, which +/// differ only in what they hold and where they live. +/// +internal static class JsonFiles +{ + internal static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// 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. + /// + internal static void WriteAtomic(string path, int version, T data) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + var temp = path + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(new SaveEnvelope(version, data), Options)); + File.Move(temp, path, overwrite: true); + } + + /// + /// Reads a save file. Absent when there is no file; Corrupt when there is one but it cannot be + /// turned into — 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. + /// + internal static LoadResult Read( + string path, + int currentVersion, + Func restore) + where TSnapshot : class + where TAggregate : class + { + if (!File.Exists(path)) return LoadResult.Absent(); + + SaveEnvelope? envelope; + try + { + envelope = JsonSerializer.Deserialize>(File.ReadAllText(path), Options); + } + catch (JsonException e) + { + return LoadResult.Corrupt($"unreadable json: {e.Message}"); + } + + if (envelope?.Data is null) + return LoadResult.Corrupt("file held no save data"); + + if (envelope.Version > currentVersion) + return LoadResult.Corrupt( + $"saved by a newer build (version {envelope.Version}, this build reads {currentVersion})"); + + if (envelope.Version < currentVersion) + return LoadResult.Corrupt( + $"version {envelope.Version} has no migration to {currentVersion}"); + + try + { + return LoadResult.Loaded(restore(envelope.Data)); + } + catch (Exception e) + { + return LoadResult.Corrupt($"snapshot rejected by the domain: {e.Message}"); + } + } +} diff --git a/progression.adapters/JsonPlaythroughStore.cs b/progression.adapters/JsonPlaythroughStore.cs new file mode 100644 index 0000000..d54d114 --- /dev/null +++ b/progression.adapters/JsonPlaythroughStore.cs @@ -0,0 +1,30 @@ +namespace Mr.BrickAdventures.Progression.Adapters; + +/// +/// Keeps each Playthrough in its own JSON file inside a directory. +/// +/// +/// Takes a directory path rather than using Godot's FileAccess. Godot resolves user:// to an +/// absolute path with ProjectSettings.GlobalizePath and passes it in, so this whole class has +/// no engine dependency and can be tested against a real temp directory. +/// +public sealed class JsonPlaythroughStore : IPlaythroughStore +{ + /// Bump only alongside a migration for the version being replaced. + private const int CurrentVersion = 1; + + private readonly string _directory; + + public JsonPlaythroughStore(string directory) => _directory = directory; + + /// One file per slot, so saving a run never rewrites the Profile or another slot. + 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 Load(PlaythroughId id) => + JsonFiles.Read( + PathFor(id), CurrentVersion, Playthrough.Restore); +} diff --git a/progression.adapters/JsonProfileStore.cs b/progression.adapters/JsonProfileStore.cs new file mode 100644 index 0000000..2cfa4c6 --- /dev/null +++ b/progression.adapters/JsonProfileStore.cs @@ -0,0 +1,20 @@ +namespace Mr.BrickAdventures.Progression.Adapters; + +/// +/// Keeps the Profile in its own JSON file, beside the Playthrough files but written independently. +/// +public sealed class JsonProfileStore : IProfileStore +{ + /// Versioned separately from Playthroughs — the two shapes drift at their own pace. + 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 Load() => + JsonFiles.Read(_path, CurrentVersion, Profile.Restore); +} diff --git a/progression.adapters/Mr.BrickAdventures.Progression.Adapters.csproj b/progression.adapters/Mr.BrickAdventures.Progression.Adapters.csproj new file mode 100644 index 0000000..a8750a0 --- /dev/null +++ b/progression.adapters/Mr.BrickAdventures.Progression.Adapters.csproj @@ -0,0 +1,21 @@ + + + + + + + + net8.0 + enable + enable + Mr.BrickAdventures.Progression.Adapters + + + true + + + + + + diff --git a/progression.adapters/SaveEnvelope.cs b/progression.adapters/SaveEnvelope.cs new file mode 100644 index 0000000..4de7b99 --- /dev/null +++ b/progression.adapters/SaveEnvelope.cs @@ -0,0 +1,10 @@ +namespace Mr.BrickAdventures.Progression.Adapters; + +/// +/// What actually goes in a save file: a version alongside the snapshot. +/// +/// +/// 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. +/// +internal sealed record SaveEnvelope(int Version, T Data); diff --git a/progression.tests/JsonPlaythroughStoreTests.cs b/progression.tests/JsonPlaythroughStoreTests.cs new file mode 100644 index 0000000..3df5867 --- /dev/null +++ b/progression.tests/JsonPlaythroughStoreTests.cs @@ -0,0 +1,121 @@ +using Mr.BrickAdventures.Progression.Adapters; + +namespace Mr.BrickAdventures.Progression.Tests; + +/// +/// 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. +/// +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); + } +} diff --git a/progression.tests/JsonProfileStoreTests.cs b/progression.tests/JsonProfileStoreTests.cs new file mode 100644 index 0000000..82c2242 --- /dev/null +++ b/progression.tests/JsonProfileStoreTests.cs @@ -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); + } +} diff --git a/progression.tests/Mr.BrickAdventures.Progression.Tests.csproj b/progression.tests/Mr.BrickAdventures.Progression.Tests.csproj index e6bbf02..5135d1c 100644 --- a/progression.tests/Mr.BrickAdventures.Progression.Tests.csproj +++ b/progression.tests/Mr.BrickAdventures.Progression.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/scripts/Resources/LevelCatalogue.cs.uid b/scripts/Resources/LevelCatalogue.cs.uid new file mode 100644 index 0000000..f3a361d --- /dev/null +++ b/scripts/Resources/LevelCatalogue.cs.uid @@ -0,0 +1 @@ +uid://cxryfiktqs7s1