diff --git a/progression.tests/InMemoryPlaythroughStore.cs b/progression.tests/InMemoryPlaythroughStore.cs new file mode 100644 index 0000000..0857a62 --- /dev/null +++ b/progression.tests/InMemoryPlaythroughStore.cs @@ -0,0 +1,34 @@ +namespace Mr.BrickAdventures.Progression.Tests; + +/// +/// The test-side adapter for . Its existence alongside the JSON store +/// is what makes the port a real seam rather than a hypothetical one. +/// +internal sealed class InMemoryPlaythroughStore : IPlaythroughStore +{ + private readonly Dictionary _saved = new(); + private readonly HashSet _corrupt = new(); + + public void Save(Playthrough playthrough) => _saved[playthrough.Id.Value] = playthrough.Export(); + + /// Simulates a file that exists but cannot be read back into a valid snapshot. + public void Corrupt(PlaythroughId id) => _corrupt.Add(id.Value); + + public LoadResult Load(PlaythroughId id) + { + if (_corrupt.Contains(id.Value)) + return LoadResult.Corrupt("snapshot could not be read"); + + if (!_saved.TryGetValue(id.Value, out var snapshot)) + return LoadResult.Absent(); + + try + { + return LoadResult.Loaded(Playthrough.Restore(snapshot)); + } + catch (Exception e) + { + return LoadResult.Corrupt(e.Message); + } + } +} diff --git a/progression.tests/InMemoryProfileStore.cs b/progression.tests/InMemoryProfileStore.cs new file mode 100644 index 0000000..0c28ac6 --- /dev/null +++ b/progression.tests/InMemoryProfileStore.cs @@ -0,0 +1,26 @@ +namespace Mr.BrickAdventures.Progression.Tests; + +internal sealed class InMemoryProfileStore : IProfileStore +{ + private ProfileSnapshot? _saved; + private bool _corrupt; + + public void Save(Profile profile) => _saved = profile.Export(); + + public void Corrupt() => _corrupt = true; + + public LoadResult Load() + { + if (_corrupt) return LoadResult.Corrupt("snapshot could not be read"); + if (_saved is null) return LoadResult.Absent(); + + try + { + return LoadResult.Loaded(Profile.Restore(_saved)); + } + catch (Exception e) + { + return LoadResult.Corrupt(e.Message); + } + } +} diff --git a/progression.tests/PlaythroughSnapshotTests.cs b/progression.tests/PlaythroughSnapshotTests.cs new file mode 100644 index 0000000..f2bb40e --- /dev/null +++ b/progression.tests/PlaythroughSnapshotTests.cs @@ -0,0 +1,33 @@ +namespace Mr.BrickAdventures.Progression.Tests; + +public class PlaythroughSnapshotTests +{ + private static readonly LevelId First = LevelId.Of("village_1"); + private static readonly LevelId Second = LevelId.Of("village_2"); + + private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second }); + + [Fact] + public void A_playthrough_survives_a_round_trip_through_its_snapshot() + { + var brickThrow = SkillId.Of("brick_throw"); + var campaign = TwoLevels(); + var original = Playthrough.StartNew( + PlaythroughId.Of(new Guid("22222222-2222-2222-2222-222222222222")), campaign, Lives.Of(3)); + + var attempt = new Attempt(First); + attempt.RecordCoinsFound(Coins.Of(50)); + attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(2), Coins.Of(30)); + original.SettleCompletion(attempt, campaign); + original.SettleDeath(new Attempt(Second)); + + var restored = Playthrough.Restore(original.Export()); + + Assert.Equal(original.Id, restored.Id); + Assert.Equal(original.Lives.Value, restored.Lives.Value); + Assert.Equal(original.Bank, restored.Bank); + Assert.Equal(original.CurrentLevel, restored.CurrentLevel); + Assert.Equal(SkillLevel.Of(2), restored.OwnedSkills[brickThrow]); + Assert.Equal(original.ClearedLevels.OrderBy(l => l.Value), restored.ClearedLevels.OrderBy(l => l.Value)); + } +} diff --git a/progression.tests/PlaythroughStoreTests.cs b/progression.tests/PlaythroughStoreTests.cs new file mode 100644 index 0000000..8e9b373 --- /dev/null +++ b/progression.tests/PlaythroughStoreTests.cs @@ -0,0 +1,47 @@ +namespace Mr.BrickAdventures.Progression.Tests; + +public class PlaythroughStoreTests +{ + private static readonly PlaythroughId Id = + PlaythroughId.Of(new Guid("33333333-3333-3333-3333-333333333333")); + + private static Campaign OneLevel() => Campaign.Of(new[] { LevelId.Of("village_1") }); + + [Fact] + public void A_playthrough_that_was_never_saved_is_absent_not_an_error() + { + var store = new InMemoryPlaythroughStore(); + + var result = store.Load(Id); + + Assert.Equal(LoadOutcome.Absent, result.Outcome); + } + + [Fact] + public void A_playthrough_that_cannot_be_read_is_corrupt_and_not_mistaken_for_absent() + { + // This is the version-bump bug. LoadGame returns false for both "no file" and "version + // mismatch", ContinueGame reads that as no save, calls StartNewGame, and overwrites the + // player's file. Absent means "offer a new game"; Corrupt means "say something went wrong". + var store = new InMemoryPlaythroughStore(); + store.Save(Playthrough.StartNew(Id, OneLevel(), Lives.Of(3))); + store.Corrupt(Id); + + var result = store.Load(Id); + + Assert.Equal(LoadOutcome.Corrupt, result.Outcome); + Assert.NotEqual(LoadOutcome.Absent, result.Outcome); + } + + [Fact] + public void A_saved_playthrough_loads_back() + { + var store = new InMemoryPlaythroughStore(); + store.Save(Playthrough.StartNew(Id, OneLevel(), Lives.Of(2))); + + var result = store.Load(Id); + + Assert.Equal(LoadOutcome.Loaded, result.Outcome); + Assert.Equal(2, result.Value!.Lives.Value); + } +} diff --git a/progression.tests/ProfileSnapshotTests.cs b/progression.tests/ProfileSnapshotTests.cs new file mode 100644 index 0000000..2fb3b2e --- /dev/null +++ b/progression.tests/ProfileSnapshotTests.cs @@ -0,0 +1,52 @@ +namespace Mr.BrickAdventures.Progression.Tests; + +public class ProfileSnapshotTests +{ + private static readonly LevelId Level = LevelId.Of("village_1"); + private static readonly AchievementId RescueTen = AchievementId.Of("rescue_ten_kids"); + + [Fact] + public void A_profile_survives_a_round_trip_through_its_snapshot() + { + var profile = Profile.Empty(); + profile.RecordTime(Level, LevelTime.Of(42.5)); + profile.UnlockAchievement(RescueTen); + profile.Record(StatName.Deaths, 7); + + var restored = Profile.Restore(profile.Export()); + + Assert.Equal(LevelTime.Of(42.5), restored.BestTimeFor(Level)); + Assert.True(restored.HasAchievement(RescueTen)); + Assert.Equal(7, restored.ReadStat(StatName.Deaths)); + } + + [Fact] + public void Restoring_a_profile_does_not_re_announce_its_achievements() + { + // If Restore replayed UnlockAchievement it would raise AchievementUnlocked for every + // achievement on every load — so the popup would fire on startup and Steam would be + // re-pushed each time. Loading is not earning. + var profile = Profile.Empty(); + profile.UnlockAchievement(RescueTen); + + var restored = Profile.Restore(profile.Export()); + + Assert.Empty(restored.DrainEvents()); + } + + [Fact] + public void An_unknown_statistic_is_dropped_rather_than_failing_the_load() + { + // A save written by a build that tracked a statistic this one no longer has. Losing one + // counter is acceptable; refusing to load the Profile — and with it every achievement and + // best time — is not. + var snapshot = new ProfileSnapshot( + BestTimes: new Dictionary(), + Achievements: Array.Empty(), + Statistics: new Dictionary { ["Deaths"] = 4, ["BricksThrown"] = 99 }); + + var restored = Profile.Restore(snapshot); + + Assert.Equal(4, restored.ReadStat(StatName.Deaths)); + } +} diff --git a/progression.tests/ProfileStoreTests.cs b/progression.tests/ProfileStoreTests.cs new file mode 100644 index 0000000..5428a65 --- /dev/null +++ b/progression.tests/ProfileStoreTests.cs @@ -0,0 +1,38 @@ +namespace Mr.BrickAdventures.Progression.Tests; + +public class ProfileStoreTests +{ + private static readonly LevelId Level = LevelId.Of("village_1"); + + [Fact] + public void On_a_first_run_there_is_no_profile_yet_and_that_is_absent_not_corrupt() + { + var store = new InMemoryProfileStore(); + + Assert.Equal(LoadOutcome.Absent, store.Load().Outcome); + } + + [Fact] + public void A_saved_profile_loads_back_with_its_best_times() + { + var store = new InMemoryProfileStore(); + var profile = Profile.Empty(); + profile.RecordTime(Level, LevelTime.Of(31.25)); + store.Save(profile); + + var result = store.Load(); + + Assert.Equal(LoadOutcome.Loaded, result.Outcome); + Assert.Equal(LevelTime.Of(31.25), result.Value!.BestTimeFor(Level)); + } + + [Fact] + public void A_profile_that_cannot_be_read_is_corrupt_so_achievements_are_not_silently_wiped() + { + var store = new InMemoryProfileStore(); + store.Save(Profile.Empty()); + store.Corrupt(); + + Assert.Equal(LoadOutcome.Corrupt, store.Load().Outcome); + } +} diff --git a/progression/IPlaythroughStore.cs b/progression/IPlaythroughStore.cs new file mode 100644 index 0000000..3a3db90 --- /dev/null +++ b/progression/IPlaythroughStore.cs @@ -0,0 +1,12 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// Where Playthroughs are kept. One per slot; the JSON adapter writes one file each, so saving a run +/// never rewrites the Profile and deleting a slot is deleting a file. +/// +public interface IPlaythroughStore +{ + void Save(Playthrough playthrough); + + LoadResult Load(PlaythroughId id); +} diff --git a/progression/IProfileStore.cs b/progression/IProfileStore.cs new file mode 100644 index 0000000..b2f39d0 --- /dev/null +++ b/progression/IProfileStore.cs @@ -0,0 +1,12 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// Where the Profile is kept. One per installation, in its own file, so unlocking an achievement +/// never rewrites a Playthrough and a corrupt slot never costs best times. +/// +public interface IProfileStore +{ + void Save(Profile profile); + + LoadResult Load(); +} diff --git a/progression/LoadResult.cs b/progression/LoadResult.cs new file mode 100644 index 0000000..e15fd4e --- /dev/null +++ b/progression/LoadResult.cs @@ -0,0 +1,26 @@ +namespace Mr.BrickAdventures.Progression; + +public enum LoadOutcome +{ + Loaded, + Absent, + Corrupt, +} + +/// +/// The result of loading something from a store: it was there, it was never there, or it was there +/// and unreadable. +/// +/// +/// Three outcomes rather than two, deliberately. The system being replaced collapsed "no save file" +/// and "version mismatch" into a single false from LoadGame, which ContinueGame +/// then read as "no save" — so it started a new game and overwrote the player's file. Absent means +/// offer a new game; Corrupt means tell the player something went wrong. +/// +public sealed record LoadResult(LoadOutcome Outcome, T? Value, string? Problem) + where T : class +{ + public static LoadResult Loaded(T value) => new(LoadOutcome.Loaded, value, null); + public static LoadResult Absent() => new(LoadOutcome.Absent, null, null); + public static LoadResult Corrupt(string problem) => new(LoadOutcome.Corrupt, null, problem); +} diff --git a/progression/Playthrough.cs b/progression/Playthrough.cs index a02d718..59b0aad 100644 --- a/progression/Playthrough.cs +++ b/progression/Playthrough.cs @@ -122,4 +122,35 @@ public sealed class Playthrough /// once after each use case and republishes. /// public IReadOnlyList DrainEvents() => _events.Drain(); + + public PlaythroughSnapshot Export() => new( + Id.Value, + Lives.Value, + Bank.Value, + CurrentLevel.Value, + _ownedSkills.ToDictionary(e => e.Key.Value, e => e.Value.Value), + _clearedLevels.Select(l => l.Value).ToList()); + + /// + /// Rebuilds a Playthrough from a snapshot, re-running every value object's factory so an invalid + /// snapshot is rejected here rather than becoming corrupt in-memory state. + /// + public static Playthrough Restore(PlaythroughSnapshot snapshot) + { + var playthrough = new Playthrough( + PlaythroughId.Of(snapshot.Id), + LevelId.Of(snapshot.CurrentLevel), + Lives.Of(snapshot.Lives)) + { + Bank = Coins.Of(snapshot.Bank), + }; + + foreach (var (skill, level) in snapshot.OwnedSkills) + playthrough._ownedSkills[SkillId.Of(skill)] = SkillLevel.Of(level); + + foreach (var level in snapshot.ClearedLevels) + playthrough._clearedLevels.Add(LevelId.Of(level)); + + return playthrough; + } } diff --git a/progression/PlaythroughSnapshot.cs b/progression/PlaythroughSnapshot.cs new file mode 100644 index 0000000..eae0724 --- /dev/null +++ b/progression/PlaythroughSnapshot.cs @@ -0,0 +1,19 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// A Playthrough's state as plain data, for crossing the persistence seam. +/// +/// +/// Deliberately primitives rather than value objects. A serialiser constructing +/// or directly would bypass their factories and every guard in them, so instead +/// re-runs those factories and a corrupt file fails at the domain +/// edge. Carries no version field — versioning and migration belong to the store adapter, so the +/// domain only ever knows the current shape. +/// +public sealed record PlaythroughSnapshot( + Guid Id, + int Lives, + int Bank, + string CurrentLevel, + IReadOnlyDictionary OwnedSkills, + IReadOnlyList ClearedLevels); diff --git a/progression/Profile.cs b/progression/Profile.cs index f9aa0bc..ee5a7cb 100644 --- a/progression/Profile.cs +++ b/progression/Profile.cs @@ -57,6 +57,40 @@ public sealed class Profile /// public IReadOnlyList DrainEvents() => _events.Drain(); + public ProfileSnapshot Export() => new( + _bestTimes.ToDictionary(e => e.Key.Value, e => e.Value.Seconds), + _achievements.Select(a => a.Value).ToList(), + _statistics.ToDictionary(e => e.Key.ToString(), e => e.Value)); + + /// + /// Rebuilds a Profile from a snapshot, re-running every value object's factory. + /// + /// + /// Writes the collections directly rather than replaying . Going + /// through the operation would raise for every achievement on + /// every load, so a subscriber pushing to Steam or showing a popup would fire on startup. + /// + public static Profile Restore(ProfileSnapshot snapshot) + { + var profile = new Profile(); + + foreach (var (level, seconds) in snapshot.BestTimes) + profile._bestTimes[LevelId.Of(level)] = LevelTime.Of(seconds); + + foreach (var achievement in snapshot.Achievements) + profile._achievements.Add(AchievementId.Of(achievement)); + + foreach (var (stat, value) in snapshot.Statistics) + { + // A statistic this build no longer tracks is dropped. Losing one counter beats refusing + // to load the Profile and with it every achievement and best time. + if (Enum.TryParse(stat, out var known)) + profile._statistics[known] = value; + } + + return profile; + } + public LevelTime? BestTimeFor(LevelId level) => _bestTimes.TryGetValue(level, out var time) ? time : null; diff --git a/progression/ProfileSnapshot.cs b/progression/ProfileSnapshot.cs new file mode 100644 index 0000000..70b4eba --- /dev/null +++ b/progression/ProfileSnapshot.cs @@ -0,0 +1,14 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// A Profile's state as plain data, for crossing the persistence seam. Primitives only, for the same +/// reasons as . +/// +/// +/// Statistics are keyed by 's name rather than its numeric value, so +/// reordering the enum cannot silently reassign a player's history. +/// +public sealed record ProfileSnapshot( + IReadOnlyDictionary BestTimes, + IReadOnlyList Achievements, + IReadOnlyDictionary Statistics);