feat(progression): use cases (slice 6)

ProgressionService holds the loaded Profile, active Playthrough and
active Attempt, so "exactly one active Attempt" has a testable home.

EnterLevel always announces LevelStarted - the reason ghost recording
and splits never worked on level 1 or after a Continue. Refuses
unreachable levels. Does not touch campaign position, so a Replay
leaves it alone.

SkillCatalogue returns null for a skill with no authored upgrades,
killing the Upgrades[0].Cost overrun. PurchaseSkill reads the current
level from the ledger first, so one Attempt can unlock then upgrade.
HandlePlayerDeath now persists - death was free if you quit.

SkillLevel.First/Next driven out here by a real caller. 53 tests.
This commit is contained in:
2026-07-28 19:10:06 +02:00
parent 003ec4b010
commit 330e10880c
8 changed files with 433 additions and 6 deletions

View File

@@ -4,12 +4,22 @@ namespace Mr.BrickAdventures.Progression;
/// How far a Skill has been upgraded. Starts at one; there is no zero, because a Playthrough
/// records only the Skills it owns and absence from that record is what "unowned" means.
/// </summary>
public readonly struct SkillLevel
public readonly struct SkillLevel : IEquatable<SkillLevel>
{
public int Value { get; }
private SkillLevel(int value) => Value = value;
/// <summary>The level a Skill is at the moment it is first bought.</summary>
public static readonly SkillLevel First = new(1);
public SkillLevel Next() => new(Value + 1);
public bool Equals(SkillLevel other) => Value == other.Value;
public override bool Equals(object? obj) => obj is SkillLevel other && Equals(other);
public override int GetHashCode() => Value;
public override string ToString() => Value.ToString();
public static SkillLevel Of(int value) => value < 1
? throw new ArgumentOutOfRangeException(nameof(value), value, "A skill level starts at 1; unowned is absence, not level 0.")
: new SkillLevel(value);