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:
34
progression.tests/InMemoryPlaythroughStore.cs
Normal file
34
progression.tests/InMemoryPlaythroughStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
progression.tests/InMemoryProfileStore.cs
Normal file
26
progression.tests/InMemoryProfileStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
progression.tests/PlaythroughSnapshotTests.cs
Normal file
33
progression.tests/PlaythroughSnapshotTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
47
progression.tests/PlaythroughStoreTests.cs
Normal file
47
progression.tests/PlaythroughStoreTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
52
progression.tests/ProfileSnapshotTests.cs
Normal file
52
progression.tests/ProfileSnapshotTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
38
progression.tests/ProfileStoreTests.cs
Normal file
38
progression.tests/ProfileStoreTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
12
progression/IPlaythroughStore.cs
Normal file
12
progression/IPlaythroughStore.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IPlaythroughStore
|
||||
{
|
||||
void Save(Playthrough playthrough);
|
||||
|
||||
LoadResult<Playthrough> Load(PlaythroughId id);
|
||||
}
|
||||
12
progression/IProfileStore.cs
Normal file
12
progression/IProfileStore.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IProfileStore
|
||||
{
|
||||
void Save(Profile profile);
|
||||
|
||||
LoadResult<Profile> Load();
|
||||
}
|
||||
26
progression/LoadResult.cs
Normal file
26
progression/LoadResult.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
public enum LoadOutcome
|
||||
{
|
||||
Loaded,
|
||||
Absent,
|
||||
Corrupt,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The result of loading something from a store: it was there, it was never there, or it was there
|
||||
/// and unreadable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three outcomes rather than two, deliberately. The system being replaced collapsed "no save file"
|
||||
/// and "version mismatch" into a single <c>false</c> from <c>LoadGame</c>, which <c>ContinueGame</c>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public sealed record LoadResult<T>(LoadOutcome Outcome, T? Value, string? Problem)
|
||||
where T : class
|
||||
{
|
||||
public static LoadResult<T> Loaded(T value) => new(LoadOutcome.Loaded, value, null);
|
||||
public static LoadResult<T> Absent() => new(LoadOutcome.Absent, null, null);
|
||||
public static LoadResult<T> Corrupt(string problem) => new(LoadOutcome.Corrupt, null, problem);
|
||||
}
|
||||
@@ -122,4 +122,35 @@ public sealed class Playthrough
|
||||
/// once after each use case and republishes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProgressionEvent> 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());
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
19
progression/PlaythroughSnapshot.cs
Normal file
19
progression/PlaythroughSnapshot.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// A Playthrough's state as plain data, for crossing the persistence seam.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately primitives rather than value objects. A serialiser constructing <see cref="LevelId"/>
|
||||
/// or <see cref="Coins"/> directly would bypass their factories and every guard in them, so instead
|
||||
/// <see cref="Playthrough.Restore"/> 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.
|
||||
/// </remarks>
|
||||
public sealed record PlaythroughSnapshot(
|
||||
Guid Id,
|
||||
int Lives,
|
||||
int Bank,
|
||||
string CurrentLevel,
|
||||
IReadOnlyDictionary<string, int> OwnedSkills,
|
||||
IReadOnlyList<string> ClearedLevels);
|
||||
@@ -57,6 +57,40 @@ public sealed class Profile
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProgressionEvent> 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));
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds a Profile from a snapshot, re-running every value object's factory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Writes the collections directly rather than replaying <see cref="UnlockAchievement"/>. Going
|
||||
/// through the operation would raise <see cref="AchievementUnlocked"/> for every achievement on
|
||||
/// every load, so a subscriber pushing to Steam or showing a popup would fire on startup.
|
||||
/// </remarks>
|
||||
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<StatName>(stat, out var known))
|
||||
profile._statistics[known] = value;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
public LevelTime? BestTimeFor(LevelId level) =>
|
||||
_bestTimes.TryGetValue(level, out var time) ? time : null;
|
||||
|
||||
|
||||
14
progression/ProfileSnapshot.cs
Normal file
14
progression/ProfileSnapshot.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// A Profile's state as plain data, for crossing the persistence seam. Primitives only, for the same
|
||||
/// reasons as <see cref="PlaythroughSnapshot"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Statistics are keyed by <see cref="StatName"/>'s name rather than its numeric value, so
|
||||
/// reordering the enum cannot silently reassign a player's history.
|
||||
/// </remarks>
|
||||
public sealed record ProfileSnapshot(
|
||||
IReadOnlyDictionary<string, double> BestTimes,
|
||||
IReadOnlyList<string> Achievements,
|
||||
IReadOnlyDictionary<string, int> Statistics);
|
||||
Reference in New Issue
Block a user