feat(progression): snapshots, ports, in-memory adapters (slice 5)

flat snapshot record per aggregate - separate files mean saving a run
doesn't rewrite the Profile, deleting a slot is deleting a file, and a
corrupt slot doesn't cost achievements or best times.

snapshots hold primitives so Restore re-runs the value-object factories;
a serialiser building LevelId directly would bypass every guard.

LoadResult has three outcomes: Absent != Corrupt. Collapsing them is the
bug where a version bump made ContinueGame overwrite the save.

Profile.Restore writes collections directly - replaying UnlockAchievement
would re-fire popups and Steam pushes on every load. 44 tests.
This commit is contained in:
2026-07-28 19:02:41 +02:00
parent 05149bb4a9
commit 003ec4b010
13 changed files with 378 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
namespace Mr.BrickAdventures.Progression.Tests;
/// <summary>
/// The test-side adapter for <see cref="IPlaythroughStore"/>. Its existence alongside the JSON store
/// is what makes the port a real seam rather than a hypothetical one.
/// </summary>
internal sealed class InMemoryPlaythroughStore : IPlaythroughStore
{
private readonly Dictionary<Guid, PlaythroughSnapshot> _saved = new();
private readonly HashSet<Guid> _corrupt = new();
public void Save(Playthrough playthrough) => _saved[playthrough.Id.Value] = playthrough.Export();
/// <summary>Simulates a file that exists but cannot be read back into a valid snapshot.</summary>
public void Corrupt(PlaythroughId id) => _corrupt.Add(id.Value);
public LoadResult<Playthrough> Load(PlaythroughId id)
{
if (_corrupt.Contains(id.Value))
return LoadResult<Playthrough>.Corrupt("snapshot could not be read");
if (!_saved.TryGetValue(id.Value, out var snapshot))
return LoadResult<Playthrough>.Absent();
try
{
return LoadResult<Playthrough>.Loaded(Playthrough.Restore(snapshot));
}
catch (Exception e)
{
return LoadResult<Playthrough>.Corrupt(e.Message);
}
}
}

View File

@@ -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<Profile> Load()
{
if (_corrupt) return LoadResult<Profile>.Corrupt("snapshot could not be read");
if (_saved is null) return LoadResult<Profile>.Absent();
try
{
return LoadResult<Profile>.Loaded(Profile.Restore(_saved));
}
catch (Exception e)
{
return LoadResult<Profile>.Corrupt(e.Message);
}
}
}

View File

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

View File

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

View File

@@ -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<string, double>(),
Achievements: Array.Empty<string>(),
Statistics: new Dictionary<string, int> { ["Deaths"] = 4, ["BricksThrown"] = 99 });
var restored = Profile.Restore(snapshot);
Assert.Equal(4, restored.ReadStat(StatName.Deaths));
}
}

View File

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