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.
This commit is contained in:
@@ -14,4 +14,10 @@ public class CoinsTests
|
|||||||
// which is the safe outcome; clamping would silently consume what wasn't there.
|
// which is the safe outcome; clamping would silently consume what wasn't there.
|
||||||
Assert.Equal(Coins.Of(10), remaining);
|
Assert.Equal(Coins.Of(10), remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_negative_quantity_of_coins_cannot_exist()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => Coins.Of(-1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
progression.tests/LevelIdTests.cs
Normal file
14
progression.tests/LevelIdTests.cs
Normal file
@@ -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<ArgumentException>(() => LevelId.Of(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,4 +10,14 @@ public class LevelTimeTests
|
|||||||
// files. Rejecting zero makes that unrepresentable rather than merely unlikely.
|
// files. Rejecting zero makes that unrepresentable rather than merely unlikely.
|
||||||
Assert.Throws<ArgumentOutOfRangeException>(() => LevelTime.Of(0));
|
Assert.Throws<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => LevelTime.Of(seconds));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
progression.tests/LivesTests.cs
Normal file
21
progression.tests/LivesTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
progression.tests/PlaythroughIdTests.cs
Normal file
13
progression.tests/PlaythroughIdTests.cs
Normal file
@@ -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<ArgumentException>(() => PlaythroughId.Of(Guid.Empty));
|
||||||
|
}
|
||||||
|
}
|
||||||
20
progression.tests/SkillIdTests.cs
Normal file
20
progression.tests/SkillIdTests.cs
Normal file
@@ -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<ArgumentException>(() => 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
21
progression/LevelId.cs
Normal file
21
progression/LevelId.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct LevelId : IEquatable<LevelId>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ public readonly struct LevelTime
|
|||||||
|
|
||||||
private LevelTime(double seconds) => Seconds = seconds;
|
private LevelTime(double seconds) => Seconds = seconds;
|
||||||
|
|
||||||
public static LevelTime Of(double seconds) => seconds <= 0
|
public static LevelTime Of(double seconds) => double.IsFinite(seconds) && seconds > 0
|
||||||
? throw new ArgumentOutOfRangeException(nameof(seconds), seconds, "A completion time must be positive.")
|
? new LevelTime(seconds)
|
||||||
: new LevelTime(seconds);
|
: throw new ArgumentOutOfRangeException(nameof(seconds), seconds, "A completion time must be a finite positive number.");
|
||||||
}
|
}
|
||||||
|
|||||||
24
progression/Lives.cs
Normal file
24
progression/Lives.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lives remaining in a Playthrough. Never negative.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Decrementing at zero floors rather than failing — unlike <see cref="Coins"/>, 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.
|
||||||
|
/// </remarks>
|
||||||
|
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);
|
||||||
|
}
|
||||||
25
progression/PlaythroughId.cs
Normal file
25
progression/PlaythroughId.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public readonly struct PlaythroughId : IEquatable<PlaythroughId>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
21
progression/SkillId.cs
Normal file
21
progression/SkillId.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable identity of a Skill, matching the authored <c>SkillData</c> name that saves already
|
||||||
|
/// reference. Identity only — the upgrade table and presentation stay in the authored asset.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct SkillId : IEquatable<SkillId>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
18
progression/StatName.cs
Normal file
18
progression/StatName.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The closed set of statistics a Profile tracks. Mirrors the five names in the old
|
||||||
|
/// <c>StatNames</c> constants, but as a type, so a typo cannot invent a sixth statistic.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public enum StatName
|
||||||
|
{
|
||||||
|
CoinsCollected,
|
||||||
|
EnemiesDefeated,
|
||||||
|
Deaths,
|
||||||
|
LevelsCompleted,
|
||||||
|
ChildrenRescued,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user