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:
@@ -156,4 +156,18 @@ public class PlaythroughTests
|
||||
Assert.False(playthrough.HasCleared(First));
|
||||
Assert.Equal(First, playthrough.CurrentLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Committing_an_attempt_that_overspent_the_bank_is_an_impossible_state()
|
||||
{
|
||||
// PurchaseSkill's affordability check makes this unreachable through the application layer.
|
||||
// Asserted directly so the guard is covered rather than being dead code nobody has run — if
|
||||
// it ever fires, the bug is a missing affordability check, not this line.
|
||||
var campaign = TwoLevels();
|
||||
var playthrough = StartWith(Lives.Of(3));
|
||||
var attempt = new Attempt(First);
|
||||
attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.First, Coins.Of(999));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => playthrough.SettleCompletion(attempt, campaign));
|
||||
}
|
||||
}
|
||||
|
||||
170
progression.tests/ProgressionServiceTests.cs
Normal file
170
progression.tests/ProgressionServiceTests.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
namespace Mr.BrickAdventures.Progression.Tests;
|
||||
|
||||
public class ProgressionServiceTests
|
||||
{
|
||||
private static readonly LevelId First = LevelId.Of("village_1");
|
||||
private static readonly LevelId Second = LevelId.Of("village_2");
|
||||
|
||||
private static readonly PlaythroughId Id =
|
||||
PlaythroughId.Of(new Guid("44444444-4444-4444-4444-444444444444"));
|
||||
|
||||
private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second });
|
||||
|
||||
private static ProgressionService Service()
|
||||
{
|
||||
var service = new ProgressionService(
|
||||
Profile.Empty(),
|
||||
new InMemoryPlaythroughStore(),
|
||||
new InMemoryProfileStore(),
|
||||
TwoLevels(),
|
||||
SkillCatalogue.Empty(),
|
||||
startingLives: Lives.Of(3));
|
||||
|
||||
service.StartPlaythrough(Id);
|
||||
return service;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entering_a_level_always_announces_that_it_started()
|
||||
{
|
||||
// Today LevelStarted is emitted only by TryToGoToNextLevel, so it never fires for the first
|
||||
// level of a new game or for anything after a Continue. GhostManager.StartRecording and the
|
||||
// speedrun timer both hang off it, which is why neither works except mid-campaign.
|
||||
var service = Service();
|
||||
|
||||
service.EnterLevel(First);
|
||||
|
||||
Assert.Contains(service.DrainEvents(), e => e is LevelStarted started && started.Level.Equals(First));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entering_a_level_the_playthrough_cannot_reach_is_refused()
|
||||
{
|
||||
var service = Service();
|
||||
|
||||
Assert.False(service.EnterLevel(Second));
|
||||
Assert.Empty(service.DrainEvents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_death_events_in_one_attempt_cost_one_life_and_persist_the_result()
|
||||
{
|
||||
// End to end this time. Also note the save: today nothing persists on death, so quitting
|
||||
// after dying gives the lives back.
|
||||
var store = new InMemoryPlaythroughStore();
|
||||
var service = new ProgressionService(
|
||||
Profile.Empty(), store, new InMemoryProfileStore(),
|
||||
TwoLevels(), SkillCatalogue.Empty(), Lives.Of(3));
|
||||
service.StartPlaythrough(Id);
|
||||
service.EnterLevel(First);
|
||||
|
||||
service.HandlePlayerDeath();
|
||||
service.HandlePlayerDeath();
|
||||
|
||||
var reloaded = store.Load(Id);
|
||||
Assert.Equal(LoadOutcome.Loaded, reloaded.Outcome);
|
||||
Assert.Equal(2, reloaded.Value!.Lives.Value);
|
||||
}
|
||||
|
||||
private static readonly SkillId BrickThrow = SkillId.Of("brick_throw");
|
||||
private static readonly SkillId BrickShield = SkillId.Of("brick_shield");
|
||||
|
||||
/// <summary>brick_throw costs 10 then 25; brick_shield has no authored upgrades at all.</summary>
|
||||
private static SkillCatalogue Skills() => SkillCatalogue.Of(
|
||||
new Dictionary<SkillId, IReadOnlyList<Coins>>
|
||||
{
|
||||
[BrickThrow] = new[] { Coins.Of(10), Coins.Of(25) },
|
||||
[BrickShield] = Array.Empty<Coins>(),
|
||||
});
|
||||
|
||||
private static ProgressionService ServiceWithSkills(IPlaythroughStore? store = null)
|
||||
{
|
||||
var service = new ProgressionService(
|
||||
Profile.Empty(), store ?? new InMemoryPlaythroughStore(), new InMemoryProfileStore(),
|
||||
TwoLevels(), Skills(), Lives.Of(3));
|
||||
service.StartPlaythrough(Id);
|
||||
service.EnterLevel(First);
|
||||
return service;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_skill_that_cannot_be_afforded_is_refused_and_records_nothing()
|
||||
{
|
||||
// The bank is empty and the attempt has found nothing, so there is no way to pay. Refusing
|
||||
// here is what keeps SettleCompletion's negative-bank assertion unreachable.
|
||||
var service = ServiceWithSkills();
|
||||
|
||||
Assert.Equal(PurchaseOutcome.NotAffordable, service.PurchaseSkill(BrickThrow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_skill_with_no_authored_upgrades_cannot_be_bought()
|
||||
{
|
||||
// Five skills currently have no upgrades/ entries. TryUnlockSkill reads Upgrades[0].Cost on
|
||||
// them, which would throw — it is unreachable today only because MarketplaceButton hides
|
||||
// them. Here it is a stated outcome instead of a hidden landmine.
|
||||
var service = ServiceWithSkills();
|
||||
|
||||
Assert.Equal(PurchaseOutcome.NotPurchasable, service.PurchaseSkill(BrickShield));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_skill_already_at_its_top_level_cannot_be_upgraded_further()
|
||||
{
|
||||
// TryUpgradeSkill guards with Level >= MaxLevel but then indexes Upgrades[skill.Level], and
|
||||
// BrickArmorSkillComponent indexes Upgrades[Level - 1] unguarded.
|
||||
var service = ServiceWithSkills();
|
||||
service.RecordCoinsFound(Coins.Of(35));
|
||||
|
||||
// Unlock to level 1 for 10, upgrade to level 2 for 25 — both within one Attempt, so this
|
||||
// also covers reading the current level from the ledger rather than only from the Playthrough.
|
||||
Assert.Equal(PurchaseOutcome.Purchased, service.PurchaseSkill(BrickThrow));
|
||||
Assert.Equal(PurchaseOutcome.Purchased, service.PurchaseSkill(BrickThrow));
|
||||
|
||||
Assert.Equal(PurchaseOutcome.AlreadyAtTopLevel, service.PurchaseSkill(BrickThrow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finishing_a_level_for_the_first_time_banks_advances_and_persists_both_stores()
|
||||
{
|
||||
var playthroughStore = new InMemoryPlaythroughStore();
|
||||
var profileStore = new InMemoryProfileStore();
|
||||
var service = new ProgressionService(
|
||||
Profile.Empty(), playthroughStore, profileStore, TwoLevels(), Skills(), Lives.Of(3));
|
||||
service.StartPlaythrough(Id);
|
||||
service.EnterLevel(First);
|
||||
service.RecordCoinsFound(Coins.Of(50));
|
||||
|
||||
service.FinishLevel(LevelTime.Of(31.5));
|
||||
|
||||
var reloaded = playthroughStore.Load(Id).Value!;
|
||||
Assert.Equal(Coins.Of(50), reloaded.Bank);
|
||||
Assert.Equal(Second, reloaded.CurrentLevel);
|
||||
Assert.True(reloaded.HasCleared(First));
|
||||
Assert.Equal(LevelTime.Of(31.5), profileStore.Load().Value!.BestTimeFor(First));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replaying_a_cleared_level_improves_the_best_time_but_banks_nothing()
|
||||
{
|
||||
var playthroughStore = new InMemoryPlaythroughStore();
|
||||
var profileStore = new InMemoryProfileStore();
|
||||
var service = new ProgressionService(
|
||||
Profile.Empty(), playthroughStore, profileStore, TwoLevels(), Skills(), Lives.Of(3));
|
||||
service.StartPlaythrough(Id);
|
||||
|
||||
service.EnterLevel(First);
|
||||
service.RecordCoinsFound(Coins.Of(50));
|
||||
service.FinishLevel(LevelTime.Of(40.0));
|
||||
|
||||
// Back for a faster run, picking up coins along the way.
|
||||
service.EnterLevel(First);
|
||||
service.RecordCoinsFound(Coins.Of(99));
|
||||
service.FinishLevel(LevelTime.Of(30.0));
|
||||
|
||||
var reloaded = playthroughStore.Load(Id).Value!;
|
||||
Assert.Equal(Coins.Of(50), reloaded.Bank);
|
||||
Assert.Equal(Second, reloaded.CurrentLevel);
|
||||
Assert.Equal(LevelTime.Of(30.0), profileStore.Load().Value!.BestTimeFor(First));
|
||||
}
|
||||
}
|
||||
@@ -81,12 +81,16 @@ public sealed class Playthrough
|
||||
/// if it is a Replay. Decides which from the Attempt's own Level, so a Replay cannot advance
|
||||
/// campaign position (ADR-0003). Does nothing if the Attempt has already ended.
|
||||
/// </summary>
|
||||
public void SettleCompletion(Attempt attempt, Campaign campaign)
|
||||
/// <returns>
|
||||
/// True when this was a First Clear and the ledger was committed. False for a Replay, or when the
|
||||
/// Attempt had already ended — in both cases nothing here changed.
|
||||
/// </returns>
|
||||
public bool SettleCompletion(Attempt attempt, Campaign campaign)
|
||||
{
|
||||
var isFirstClear = !HasCleared(attempt.Level);
|
||||
|
||||
if (!attempt.TryEndInCompletion()) return;
|
||||
if (!isFirstClear) return;
|
||||
if (!attempt.TryEndInCompletion()) return false;
|
||||
if (!isFirstClear) return false;
|
||||
|
||||
if (!Bank.Add(attempt.CoinsFound).TrySubtract(attempt.CoinsSpent, out var settled))
|
||||
throw new InvalidOperationException(
|
||||
@@ -101,20 +105,25 @@ public sealed class Playthrough
|
||||
|
||||
if (campaign.Next(attempt.Level) is { } next)
|
||||
CurrentLevel = next;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the given Attempt in death and charges one life for it. Does nothing if the Attempt has
|
||||
/// already ended, so a repeated death event costs no further lives.
|
||||
/// </summary>
|
||||
public void SettleDeath(Attempt attempt)
|
||||
/// <returns>False if the Attempt had already ended, in which case nothing changed.</returns>
|
||||
public bool SettleDeath(Attempt attempt)
|
||||
{
|
||||
if (!attempt.TryEndInDeath()) return;
|
||||
if (!attempt.TryEndInDeath()) return false;
|
||||
|
||||
Lives = Lives.Decrement();
|
||||
|
||||
if (Lives.IsExhausted)
|
||||
_events.Raise(new RunEnded());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -15,3 +15,10 @@ public sealed record RunEnded : ProgressionEvent;
|
||||
/// popup or pushes to Steam cannot fire twice however often it is told.
|
||||
/// </summary>
|
||||
public sealed record AchievementUnlocked(AchievementId Achievement) : ProgressionEvent;
|
||||
|
||||
/// <summary>
|
||||
/// Raised whenever a Level is entered — every time, including the first Level of a new Playthrough
|
||||
/// and the first Level after resuming one. Ghost recording and the speedrun timer both start from
|
||||
/// this, so an entry path that does not announce is an entry path where neither works.
|
||||
/// </summary>
|
||||
public sealed record LevelStarted(LevelId Level) : ProgressionEvent;
|
||||
|
||||
155
progression/ProgressionService.cs
Normal file
155
progression/ProgressionService.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// The application layer for player progression: holds what is currently loaded — the Profile, the
|
||||
/// active Playthrough, the active Attempt — and exposes the operations gameplay triggers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One class rather than a class per use case, because they all act on the same loaded state. Split
|
||||
/// apart, that state would have to live in the Godot autoload, where the invariant "there is exactly
|
||||
/// one active Attempt" could not be tested.
|
||||
/// </remarks>
|
||||
public sealed class ProgressionService
|
||||
{
|
||||
private readonly IPlaythroughStore _playthroughs;
|
||||
private readonly IProfileStore _profiles;
|
||||
private readonly Campaign _campaign;
|
||||
private readonly SkillCatalogue _skills;
|
||||
private readonly Lives _startingLives;
|
||||
private readonly EventLog _events = new();
|
||||
|
||||
private Playthrough? _playthrough;
|
||||
private Attempt? _attempt;
|
||||
|
||||
public ProgressionService(
|
||||
Profile profile,
|
||||
IPlaythroughStore playthroughs,
|
||||
IProfileStore profiles,
|
||||
Campaign campaign,
|
||||
SkillCatalogue skills,
|
||||
Lives startingLives)
|
||||
{
|
||||
Profile = profile;
|
||||
_playthroughs = playthroughs;
|
||||
_profiles = profiles;
|
||||
_campaign = campaign;
|
||||
_skills = skills;
|
||||
_startingLives = startingLives;
|
||||
}
|
||||
|
||||
public Profile Profile { get; }
|
||||
|
||||
public void StartPlaythrough(PlaythroughId id)
|
||||
{
|
||||
_playthrough = Playthrough.StartNew(id, _campaign, _startingLives);
|
||||
_attempt = null;
|
||||
_playthroughs.Save(_playthrough);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins an Attempt at <paramref name="level"/> and announces it. Does not touch campaign
|
||||
/// position — that advances only on a First Clear, so a Replay leaves it alone.
|
||||
/// </summary>
|
||||
/// <returns>False if there is no active Playthrough, or the Level is not reachable in it.</returns>
|
||||
public bool EnterLevel(LevelId level)
|
||||
{
|
||||
if (_playthrough is null) return false;
|
||||
if (!_playthrough.ReachableLevels(_campaign).Contains(level)) return false;
|
||||
|
||||
_attempt = new Attempt(level);
|
||||
_events.Raise(new LevelStarted(level));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records coins picked up during the active Attempt. They are banked only by a First Clear.
|
||||
/// </summary>
|
||||
public void RecordCoinsFound(Coins amount) => _attempt?.RecordCoinsFound(amount);
|
||||
|
||||
/// <summary>
|
||||
/// Buys the next level of a Skill, charging it to the active Attempt's ledger — so dying undoes
|
||||
/// both the spend and the Skill, and only a First Clear makes either permanent.
|
||||
/// </summary>
|
||||
public PurchaseOutcome PurchaseSkill(SkillId skill)
|
||||
{
|
||||
if (_playthrough is null || _attempt is null) return PurchaseOutcome.NotPlaying;
|
||||
|
||||
if (_skills.MaxLevelOf(skill) is not { } topLevel) return PurchaseOutcome.NotPurchasable;
|
||||
|
||||
var next = CurrentLevelOf(skill) is { } owned ? owned.Next() : SkillLevel.First;
|
||||
if (next.Value > topLevel.Value) return PurchaseOutcome.AlreadyAtTopLevel;
|
||||
|
||||
if (_skills.CostToReach(skill, next) is not { } cost) return PurchaseOutcome.AlreadyAtTopLevel;
|
||||
if (!_playthrough.CanAfford(_attempt, cost)) return PurchaseOutcome.NotAffordable;
|
||||
|
||||
return _attempt.TryRecordPurchase(skill, next, cost)
|
||||
? PurchaseOutcome.Purchased
|
||||
: PurchaseOutcome.NotPlaying;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The level a Skill is at right now: what this Attempt has bought if anything, otherwise what
|
||||
/// the Playthrough owns. Reading only the Playthrough would let the same level be bought twice
|
||||
/// within one Attempt.
|
||||
/// </summary>
|
||||
private SkillLevel? CurrentLevelOf(SkillId skill)
|
||||
{
|
||||
if (_attempt is not null && _attempt.SkillsBought.TryGetValue(skill, out var bought))
|
||||
return bought;
|
||||
|
||||
return _playthrough is not null && _playthrough.OwnedSkills.TryGetValue(skill, out var owned)
|
||||
? owned
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Charges the active Attempt's death to the Playthrough and persists it. Repeated calls for one
|
||||
/// Attempt cost one life — the Playthrough defers to the Attempt's terminal outcome (ADR-0005),
|
||||
/// so overlapping hazards or a hazard re-firing before the scene reloads are harmless.
|
||||
/// </summary>
|
||||
public void HandlePlayerDeath()
|
||||
{
|
||||
if (_playthrough is null || _attempt is null) return;
|
||||
|
||||
if (!_playthrough.SettleDeath(_attempt)) return;
|
||||
|
||||
_playthroughs.Save(_playthrough);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settles a completed Level. The Best Time and the completion statistic are recorded either way
|
||||
/// (ADR-0003), while banking and advancing happen only on a First Clear — which the Playthrough
|
||||
/// decides for itself from the Attempt's Level.
|
||||
/// </summary>
|
||||
public void FinishLevel(LevelTime time)
|
||||
{
|
||||
if (_playthrough is null || _attempt is null) return;
|
||||
if (_attempt.Outcome != AttemptOutcome.InProgress) return;
|
||||
|
||||
var level = _attempt.Level;
|
||||
|
||||
// Counts completions, including Replays — deliberately a different fact from
|
||||
// ClearedLevels.Count, which counts distinct Levels. Conflating the two is what made the old
|
||||
// levels_completed statistic disagree with CompletedLevels.
|
||||
Profile.Record(StatName.LevelsCompleted, 1);
|
||||
Profile.RecordTime(level, time);
|
||||
|
||||
var wasFirstClear = _playthrough.SettleCompletion(_attempt, _campaign);
|
||||
_attempt = null;
|
||||
|
||||
if (wasFirstClear) _playthroughs.Save(_playthrough);
|
||||
_profiles.Save(Profile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the events raised by this service and both aggregates. The composition root drains once
|
||||
/// after each operation and republishes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProgressionEvent> DrainEvents()
|
||||
{
|
||||
var drained = _events.Drain().ToList();
|
||||
drained.AddRange(Profile.DrainEvents());
|
||||
if (_playthrough is not null) drained.AddRange(_playthrough.DrainEvents());
|
||||
return drained;
|
||||
}
|
||||
}
|
||||
22
progression/PurchaseOutcome.cs
Normal file
22
progression/PurchaseOutcome.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// Why a Skill purchase did or did not happen. Named outcomes rather than a bool, because the
|
||||
/// marketplace wants to say something different for each.
|
||||
/// </summary>
|
||||
public enum PurchaseOutcome
|
||||
{
|
||||
Purchased,
|
||||
|
||||
/// <summary>The Bank plus this Attempt's coins, less what it has already spent, is not enough.</summary>
|
||||
NotAffordable,
|
||||
|
||||
/// <summary>The Skill is already at the top level its authored upgrade table defines.</summary>
|
||||
AlreadyAtTopLevel,
|
||||
|
||||
/// <summary>The Skill has no authored upgrades, so it cannot be bought at any price.</summary>
|
||||
NotPurchasable,
|
||||
|
||||
/// <summary>There is no Playthrough loaded, or no Attempt in progress to charge it to.</summary>
|
||||
NotPlaying,
|
||||
}
|
||||
40
progression/SkillCatalogue.cs
Normal file
40
progression/SkillCatalogue.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// The authored upgrade table for every Skill: what each level costs to reach. Built from
|
||||
/// <c>SkillData</c> resources by an adapter — same pattern as <see cref="Campaign"/>, so no port is
|
||||
/// needed and a test can construct one directly.
|
||||
/// </summary>
|
||||
public sealed class SkillCatalogue
|
||||
{
|
||||
private readonly Dictionary<SkillId, IReadOnlyList<Coins>> _costs;
|
||||
|
||||
private SkillCatalogue(Dictionary<SkillId, IReadOnlyList<Coins>> costs) => _costs = costs;
|
||||
|
||||
public static SkillCatalogue Empty() => new(new Dictionary<SkillId, IReadOnlyList<Coins>>());
|
||||
|
||||
/// <summary>
|
||||
/// Builds a catalogue. Each Skill's list is the cost to reach level 1, level 2, and so on, so a
|
||||
/// Skill with an empty list cannot be bought at all.
|
||||
/// </summary>
|
||||
public static SkillCatalogue Of(IReadOnlyDictionary<SkillId, IReadOnlyList<Coins>> costsByLevel) =>
|
||||
new(costsByLevel.ToDictionary(e => e.Key, e => e.Value));
|
||||
|
||||
/// <summary>
|
||||
/// The highest level this Skill can reach, or null when it has no authored upgrades and so
|
||||
/// cannot be bought. Returning null is what stops <c>Upgrades[0].Cost</c> from throwing on the
|
||||
/// five Skills that currently have no upgrade entries.
|
||||
/// </summary>
|
||||
public SkillLevel? MaxLevelOf(SkillId skill) =>
|
||||
_costs.TryGetValue(skill, out var costs) && costs.Count > 0
|
||||
? SkillLevel.Of(costs.Count)
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// What it costs to reach <paramref name="level"/>, or null if this Skill does not go that high.
|
||||
/// </summary>
|
||||
public Coins? CostToReach(SkillId skill, SkillLevel level) =>
|
||||
_costs.TryGetValue(skill, out var costs) && level.Value <= costs.Count
|
||||
? costs[level.Value - 1]
|
||||
: null;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user