feat(progression): Coins, LevelTime, SkillLevel core invariants (slice 1)
three bug-encoding invariants, one red/green cycle each: - Coins.TrySubtract fails instead of clamping - LevelTime rejects zero (continued-run zero best-times) - SkillLevel has no zero (unowned is absence, not Level=1) plan: drop signed NetCoins; add Attempt outcome as idempotency guard
This commit is contained in:
@@ -71,18 +71,38 @@ Invariants to drive out with tests:
|
|||||||
|
|
||||||
## Slice 2 — Attempt · TDD
|
## Slice 2 — Attempt · TDD
|
||||||
|
|
||||||
The ledger from ADR-0002: `CoinsFound`, `CoinsSpent`, `SkillsBought`.
|
The ledger from ADR-0002: `CoinsFound`, `CoinsSpent`, `SkillsBought`, plus an outcome.
|
||||||
|
|
||||||
- `RecordCoinsFound(Coins)`
|
- `RecordCoinsFound(Coins)`
|
||||||
- `RecordPurchase(SkillId, Coins cost)`
|
- `RecordPurchase(SkillId, Coins cost)`
|
||||||
- `NetCoins` — may be negative, when purchases drew against the Bank
|
- `Outcome` — `InProgress | Died | Cleared`
|
||||||
|
- `bool TryEndInDeath()` / `bool TryEndInCompletion()` — return false if the Attempt already ended
|
||||||
- No `Discard` method: discarding is throwing the object away.
|
- No `Discard` method: discarding is throwing the object away.
|
||||||
|
|
||||||
|
`CoinsFound` and `CoinsSpent` are both non-negative. There is no signed net value — settlement is
|
||||||
|
`bank.Add(found).TrySubtract(spent)` in `Playthrough.Commit`, which in that order cannot go negative given
|
||||||
|
affordability was checked at purchase time.
|
||||||
|
|
||||||
|
> Corrects an earlier draft of this plan, which had `NetCoins` "may be negative". That would have forced `Coins`
|
||||||
|
> to permit negatives and defeated the point of the type.
|
||||||
|
|
||||||
|
**The outcome is the idempotency guard, and it is the reason this slice matters.** An Attempt is the thing that
|
||||||
|
dies or is cleared, and it can only do either once. `Lives` flooring at zero does not solve this: losing two lives
|
||||||
|
from a single death takes you from 3 to 1, which no clamp catches.
|
||||||
|
|
||||||
Tests worth writing:
|
Tests worth writing:
|
||||||
|
|
||||||
- Spending more than found is representable — the shortfall is settled at Commit, not at purchase time.
|
- **An Attempt cannot end twice.** Second `TryEndInDeath` returns false and changes nothing.
|
||||||
- Net coins after found 10, spent 30 is −20.
|
- **An Attempt that died cannot then be cleared**, and vice versa.
|
||||||
- Nothing on Attempt persists or mutates anything else.
|
- Spending more than found is representable — the shortfall is settled at Commit, not rejected here.
|
||||||
|
- Recording anything after the Attempt ended is refused.
|
||||||
|
|
||||||
|
Two real call sites motivate this. Death: overlapping hazards in a kill zone, or a hazard re-triggering during the
|
||||||
|
two seconds before `DeathScreen` reloads the scene — the player node still exists with zero health. Completion:
|
||||||
|
`ExitDoorComponent:46` and `ConsoleManager.GoToNextLevelCommand:99` both call `OnLevelComplete()`.
|
||||||
|
|
||||||
|
`HealthComponent.ApplyHealthChange` currently absorbs repeat deaths via its `delta == 0` early return, but that is
|
||||||
|
incidental and `Health` is a public `[Export]` setter that bypasses it. Do not rely on the engine side.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,9 +121,12 @@ This is where the report's bugs become tests. Write these first, watch them fail
|
|||||||
- **Losing the last life sets lives to zero *and* raises `RunEnded` in one operation.** This is the last-life
|
- **Losing the last life sets lives to zero *and* raises `RunEnded` in one operation.** This is the last-life
|
||||||
soft-lock — today two screens read `Lives` before the handler writes it. Make it one atomic operation and the
|
soft-lock — today two screens read `Lives` before the handler writes it. Make it one atomic operation and the
|
||||||
race cannot be expressed.
|
race cannot be expressed.
|
||||||
|
- **Two deaths in one Attempt cost one life.** Pairs with slice 2's outcome guard; assert it here at the
|
||||||
|
Playthrough level too, because this is the invariant a player would actually notice.
|
||||||
- **`Commit` on an already-cleared level does not advance and does not bank** (ADR-0003).
|
- **`Commit` on an already-cleared level does not advance and does not bank** (ADR-0003).
|
||||||
- **`Commit` applies `NetCoins`**, so an Attempt that overspent reduces the Bank correctly, and a discarded
|
- **`Commit` settles as `bank.Add(found).TrySubtract(spent)`**, in that order, so an Attempt that overspent
|
||||||
Attempt leaves the Bank untouched — the "buy mid-level, die, lose coins *and* skill" bug.
|
reduces the Bank correctly and can never drive it negative. A discarded Attempt leaves the Bank untouched —
|
||||||
|
the "buy mid-level, die, lose coins *and* skill" bug.
|
||||||
- **`ReachableLevels` is computed** from `ClearedLevels` plus level order, never stored. `UnlockedLevels` and
|
- **`ReachableLevels` is computed** from `ClearedLevels` plus level order, never stored. `UnlockedLevels` and
|
||||||
`IsLevelUnlocked` are deleted, not ported.
|
`IsLevelUnlocked` are deleted, not ported.
|
||||||
- **`Bank` cannot go negative** even through Commit.
|
- **`Bank` cannot go negative** even through Commit.
|
||||||
|
|||||||
17
progression.tests/CoinsTests.cs
Normal file
17
progression.tests/CoinsTests.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class CoinsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Subtracting_more_than_held_fails_and_leaves_the_amount_untouched()
|
||||||
|
{
|
||||||
|
var held = Coins.Of(10);
|
||||||
|
|
||||||
|
var succeeded = held.TrySubtract(Coins.Of(30), out var remaining);
|
||||||
|
|
||||||
|
Assert.False(succeeded);
|
||||||
|
// Not clamped to zero. A caller that ignores the bool sees coins un-spent,
|
||||||
|
// which is the safe outcome; clamping would silently consume what wasn't there.
|
||||||
|
Assert.Equal(Coins.Of(10), remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
progression.tests/LevelTimeTests.cs
Normal file
13
progression.tests/LevelTimeTests.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class LevelTimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Zero_is_not_a_completion_time()
|
||||||
|
{
|
||||||
|
// GetCurrentLevelTime() returns 0 whenever the speedrun timer was never started, which
|
||||||
|
// today is every continued run — so zero-second "best times" get written to the ghost
|
||||||
|
// files. Rejecting zero makes that unrepresentable rather than merely unlikely.
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => LevelTime.Of(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
13
progression.tests/SkillLevelTests.cs
Normal file
13
progression.tests/SkillLevelTests.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class SkillLevelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void There_is_no_level_zero_because_unowned_is_absence()
|
||||||
|
{
|
||||||
|
// SkillData.Level defaults to 1 today, so "not owned" and "owned at level 1" are the same
|
||||||
|
// value and nothing can tell them apart. A Playthrough records only the skills it owns, so
|
||||||
|
// absence from that record is the only way to say "unowned" — level zero must not exist.
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => SkillLevel.Of(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
37
progression/Coins.cs
Normal file
37
progression/Coins.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A non-negative quantity of coins. Amounts only — there is no signed coin value; a shortfall is
|
||||||
|
/// a failed subtraction the caller must handle, never a negative total.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct Coins : IEquatable<Coins>
|
||||||
|
{
|
||||||
|
public int Value { get; }
|
||||||
|
|
||||||
|
private Coins(int value) => Value = value;
|
||||||
|
|
||||||
|
public static Coins Of(int value) => value < 0
|
||||||
|
? throw new ArgumentOutOfRangeException(nameof(value), value, "Coins cannot be negative.")
|
||||||
|
: new Coins(value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtracts <paramref name="amount"/>. Returns false when the amount exceeds what is held,
|
||||||
|
/// leaving <paramref name="remaining"/> untouched rather than clamping to zero.
|
||||||
|
/// </summary>
|
||||||
|
public bool TrySubtract(Coins amount, out Coins remaining)
|
||||||
|
{
|
||||||
|
if (amount.Value > Value)
|
||||||
|
{
|
||||||
|
remaining = this;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = new Coins(Value - amount.Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(Coins other) => Value == other.Value;
|
||||||
|
public override bool Equals(object? obj) => obj is Coins other && Equals(other);
|
||||||
|
public override int GetHashCode() => Value;
|
||||||
|
public override string ToString() => Value.ToString();
|
||||||
|
}
|
||||||
16
progression/LevelTime.cs
Normal file
16
progression/LevelTime.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A completion time for one Level. Strictly positive — a zero time means the clock was never
|
||||||
|
/// running, which is a defect rather than a fast run.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct LevelTime
|
||||||
|
{
|
||||||
|
public double Seconds { get; }
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
16
progression/SkillLevel.cs
Normal file
16
progression/SkillLevel.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 int Value { get; }
|
||||||
|
|
||||||
|
private SkillLevel(int value) => Value = value;
|
||||||
|
|
||||||
|
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