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:
2026-07-28 18:22:46 +02:00
parent c3bbf3282a
commit 313a5315b1
7 changed files with 142 additions and 7 deletions

View File

@@ -71,18 +71,38 @@ Invariants to drive out with tests:
## 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)`
- `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.
`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:
- Spending more than found is representable — the shortfall is settled at Commit, not at purchase time.
- Net coins after found 10, spent 30 is −20.
- Nothing on Attempt persists or mutates anything else.
- **An Attempt cannot end twice.** Second `TryEndInDeath` returns false and changes nothing.
- **An Attempt that died cannot then be cleared**, and vice versa.
- 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
soft-lock — today two screens read `Lives` before the handler writes it. Make it one atomic operation and the
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` applies `NetCoins`**, so an Attempt that overspent reduces the Bank correctly, and a discarded
Attempt leaves the Bank untouched — the "buy mid-level, die, lose coins *and* skill" bug.
- **`Commit` settles as `bank.Add(found).TrySubtract(spent)`**, in that order, so an Attempt that overspent
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
`IsLevelUnlocked` are deleted, not ported.
- **`Bank` cannot go negative** even through Commit.

View 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);
}
}

View 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));
}
}

View 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
View 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
View 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
View 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);
}