From 9767941c65403601a83ac7933a71a3d65f0601df Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 28 Jul 2026 18:27:26 +0200 Subject: [PATCH] feat(progression): remaining slice 1 value objects Lives (floors at zero, exhaustion observable), LevelId, SkillId, PlaythroughId (never self-generates, keeps tests deterministic), StatName enum, LevelTime rejects NaN/infinity. 14 tests. Arithmetic/comparison members deferred to the slices whose behaviour needs them rather than added speculatively. --- progression.tests/CoinsTests.cs | 6 ++++++ progression.tests/LevelIdTests.cs | 14 ++++++++++++++ progression.tests/LevelTimeTests.cs | 10 ++++++++++ progression.tests/LivesTests.cs | 21 +++++++++++++++++++++ progression.tests/PlaythroughIdTests.cs | 13 +++++++++++++ progression.tests/SkillIdTests.cs | 20 ++++++++++++++++++++ progression/LevelId.cs | 21 +++++++++++++++++++++ progression/LevelTime.cs | 6 +++--- progression/Lives.cs | 24 ++++++++++++++++++++++++ progression/PlaythroughId.cs | 25 +++++++++++++++++++++++++ progression/SkillId.cs | 21 +++++++++++++++++++++ progression/StatName.cs | 18 ++++++++++++++++++ 12 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 progression.tests/LevelIdTests.cs create mode 100644 progression.tests/LivesTests.cs create mode 100644 progression.tests/PlaythroughIdTests.cs create mode 100644 progression.tests/SkillIdTests.cs create mode 100644 progression/LevelId.cs create mode 100644 progression/Lives.cs create mode 100644 progression/PlaythroughId.cs create mode 100644 progression/SkillId.cs create mode 100644 progression/StatName.cs diff --git a/progression.tests/CoinsTests.cs b/progression.tests/CoinsTests.cs index 95f5970..82d8389 100644 --- a/progression.tests/CoinsTests.cs +++ b/progression.tests/CoinsTests.cs @@ -14,4 +14,10 @@ public class CoinsTests // which is the safe outcome; clamping would silently consume what wasn't there. Assert.Equal(Coins.Of(10), remaining); } + + [Fact] + public void A_negative_quantity_of_coins_cannot_exist() + { + Assert.Throws(() => Coins.Of(-1)); + } } diff --git a/progression.tests/LevelIdTests.cs b/progression.tests/LevelIdTests.cs new file mode 100644 index 0000000..23f6e4a --- /dev/null +++ b/progression.tests/LevelIdTests.cs @@ -0,0 +1,14 @@ +namespace Mr.BrickAdventures.Progression.Tests; + +public class LevelIdTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + public void A_blank_id_is_not_a_level(string value) + { + // Levels are addressed by a stable authored id, never by position in a list. An unset + // field in a .tres would otherwise become a valid id that silently collides with itself. + Assert.Throws(() => LevelId.Of(value)); + } +} diff --git a/progression.tests/LevelTimeTests.cs b/progression.tests/LevelTimeTests.cs index 1815a92..fbd57af 100644 --- a/progression.tests/LevelTimeTests.cs +++ b/progression.tests/LevelTimeTests.cs @@ -10,4 +10,14 @@ public class LevelTimeTests // files. Rejecting zero makes that unrepresentable rather than merely unlikely. Assert.Throws(() => LevelTime.Of(0)); } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void A_time_that_is_not_a_finite_number_is_rejected(double seconds) + { + // Times are derived from subtracting tick counts, so a stale or unset start time can + // produce NaN or infinity rather than a plausible-but-wrong number. + Assert.Throws(() => LevelTime.Of(seconds)); + } } diff --git a/progression.tests/LivesTests.cs b/progression.tests/LivesTests.cs new file mode 100644 index 0000000..ecc7496 --- /dev/null +++ b/progression.tests/LivesTests.cs @@ -0,0 +1,21 @@ +namespace Mr.BrickAdventures.Progression.Tests; + +public class LivesTests +{ + [Fact] + public void Losing_the_last_life_exhausts_them() + { + var remaining = Lives.Of(1).Decrement(); + + Assert.Equal(0, remaining.Value); + Assert.True(remaining.IsExhausted); + } + + [Fact] + public void Decrementing_when_already_exhausted_stays_at_zero() + { + var remaining = Lives.Of(0).Decrement(); + + Assert.Equal(0, remaining.Value); + } +} diff --git a/progression.tests/PlaythroughIdTests.cs b/progression.tests/PlaythroughIdTests.cs new file mode 100644 index 0000000..3191e33 --- /dev/null +++ b/progression.tests/PlaythroughIdTests.cs @@ -0,0 +1,13 @@ +namespace Mr.BrickAdventures.Progression.Tests; + +public class PlaythroughIdTests +{ + [Fact] + public void The_empty_guid_is_not_a_playthrough() + { + // Ids arrive from an adapter, never from Guid.NewGuid() inside the domain — otherwise every + // test touching identity would be non-deterministic. That makes a default-valued guid a + // realistic mistake worth refusing. + Assert.Throws(() => PlaythroughId.Of(Guid.Empty)); + } +} diff --git a/progression.tests/SkillIdTests.cs b/progression.tests/SkillIdTests.cs new file mode 100644 index 0000000..bccd186 --- /dev/null +++ b/progression.tests/SkillIdTests.cs @@ -0,0 +1,20 @@ +namespace Mr.BrickAdventures.Progression.Tests; + +public class SkillIdTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + public void A_blank_id_is_not_a_skill(string value) + { + Assert.Throws(() => SkillId.Of(value)); + } + + [Fact] + public void Ids_with_the_same_text_are_the_same_skill() + { + // Owned skills are held in a dictionary keyed by id, so value equality is what makes the + // type usable at all rather than a convenience. + Assert.Equal(SkillId.Of("brick_throw"), SkillId.Of("brick_throw")); + } +} diff --git a/progression/LevelId.cs b/progression/LevelId.cs new file mode 100644 index 0000000..9c3b4ca --- /dev/null +++ b/progression/LevelId.cs @@ -0,0 +1,21 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// Stable identity of a Level. Never a position in a list — Playthroughs and Best Times both +/// reference Levels, so ids must survive inserting and reordering. +/// +public readonly struct LevelId : IEquatable +{ + public string Value { get; } + + private LevelId(string value) => Value = value; + + public static LevelId Of(string value) => string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("A level id cannot be blank.", nameof(value)) + : new LevelId(value); + + public bool Equals(LevelId other) => string.Equals(Value, other.Value, StringComparison.Ordinal); + public override bool Equals(object? obj) => obj is LevelId other && Equals(other); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty); + public override string ToString() => Value; +} diff --git a/progression/LevelTime.cs b/progression/LevelTime.cs index 3f45271..381dfad 100644 --- a/progression/LevelTime.cs +++ b/progression/LevelTime.cs @@ -10,7 +10,7 @@ public readonly struct LevelTime private LevelTime(double seconds) => Seconds = seconds; - public static LevelTime Of(double seconds) => seconds <= 0 - ? throw new ArgumentOutOfRangeException(nameof(seconds), seconds, "A completion time must be positive.") - : new LevelTime(seconds); + public static LevelTime Of(double seconds) => double.IsFinite(seconds) && seconds > 0 + ? new LevelTime(seconds) + : throw new ArgumentOutOfRangeException(nameof(seconds), seconds, "A completion time must be a finite positive number."); } diff --git a/progression/Lives.cs b/progression/Lives.cs new file mode 100644 index 0000000..6ed5ef3 --- /dev/null +++ b/progression/Lives.cs @@ -0,0 +1,24 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// Lives remaining in a Playthrough. Never negative. +/// +/// +/// Decrementing at zero floors rather than failing — unlike , where a shortfall +/// is a game rule the caller must handle. Flooring here is purely defensive; the invariant that one +/// death costs exactly one life is enforced by the Attempt's outcome, not by this type. +/// +public readonly struct Lives +{ + public int Value { get; } + + private Lives(int value) => Value = value; + + public static Lives Of(int value) => value < 0 + ? throw new ArgumentOutOfRangeException(nameof(value), value, "Lives cannot be negative.") + : new Lives(value); + + public bool IsExhausted => Value == 0; + + public Lives Decrement() => new(Value == 0 ? 0 : Value - 1); +} diff --git a/progression/PlaythroughId.cs b/progression/PlaythroughId.cs new file mode 100644 index 0000000..0c52363 --- /dev/null +++ b/progression/PlaythroughId.cs @@ -0,0 +1,25 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// Identity of one Playthrough. Several Playthroughs exist at once and the player picks between +/// them; "slot" is the word the UI uses for the same thing. +/// +/// +/// Deliberately has no factory that generates a value. Ids are created by an adapter and passed in, +/// so nothing in the domain depends on a source of randomness. +/// +public readonly struct PlaythroughId : IEquatable +{ + public Guid Value { get; } + + private PlaythroughId(Guid value) => Value = value; + + public static PlaythroughId Of(Guid value) => value == Guid.Empty + ? throw new ArgumentException("A playthrough id cannot be the empty guid.", nameof(value)) + : new PlaythroughId(value); + + public bool Equals(PlaythroughId other) => Value.Equals(other.Value); + public override bool Equals(object? obj) => obj is PlaythroughId other && Equals(other); + public override int GetHashCode() => Value.GetHashCode(); + public override string ToString() => Value.ToString(); +} diff --git a/progression/SkillId.cs b/progression/SkillId.cs new file mode 100644 index 0000000..52e4f77 --- /dev/null +++ b/progression/SkillId.cs @@ -0,0 +1,21 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// Stable identity of a Skill, matching the authored SkillData name that saves already +/// reference. Identity only — the upgrade table and presentation stay in the authored asset. +/// +public readonly struct SkillId : IEquatable +{ + public string Value { get; } + + private SkillId(string value) => Value = value; + + public static SkillId Of(string value) => string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("A skill id cannot be blank.", nameof(value)) + : new SkillId(value); + + public bool Equals(SkillId other) => string.Equals(Value, other.Value, StringComparison.Ordinal); + public override bool Equals(object? obj) => obj is SkillId other && Equals(other); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty); + public override string ToString() => Value; +} diff --git a/progression/StatName.cs b/progression/StatName.cs new file mode 100644 index 0000000..d4e5731 --- /dev/null +++ b/progression/StatName.cs @@ -0,0 +1,18 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// The closed set of statistics a Profile tracks. Mirrors the five names in the old +/// StatNames constants, but as a type, so a typo cannot invent a sixth statistic. +/// +/// +/// Persisted names live at the persistence seam, not here — the store adapter maps these to and +/// from the wire strings and decides what to do with unknown keys from an older save. +/// +public enum StatName +{ + CoinsCollected, + EnemiesDefeated, + Deaths, + LevelsCompleted, + ChildrenRescued, +}