feat(progression): Attempt ledger with terminal-outcome guard (slice 2)

Attempt carries its LevelId so settlement decides First Clear vs Replay
without trusting the caller. Outcome is terminal and refuses to change:
one death costs one life, one completion banks once.

Coins.Zero/Add driven out by the ledger rather than added up front.
21 tests. ADR-0005: Playthrough settles Attempts, has no LoseLife.
This commit is contained in:
2026-07-28 18:40:20 +02:00
parent 9767941c65
commit 40bd5c1d86
6 changed files with 232 additions and 4 deletions

View File

@@ -0,0 +1,98 @@
namespace Mr.BrickAdventures.Progression.Tests;
public class AttemptTests
{
[Fact]
public void An_attempt_cannot_die_twice()
{
// Overlapping hazards in a kill zone, or a hazard re-firing during the two seconds before
// DeathScreen reloads the scene, must not cost two lives. The Attempt refuses the second
// death, so no caller has to remember to guard against it.
var attempt = new Attempt(LevelId.Of("village_1"));
Assert.True(attempt.TryEndInDeath());
Assert.False(attempt.TryEndInDeath());
Assert.Equal(AttemptOutcome.Died, attempt.Outcome);
}
[Fact]
public void An_attempt_that_died_cannot_then_be_cleared()
{
var attempt = new Attempt(LevelId.Of("village_1"));
attempt.TryEndInDeath();
Assert.False(attempt.TryEndInCompletion());
Assert.Equal(AttemptOutcome.Died, attempt.Outcome);
}
[Fact]
public void An_attempt_that_was_cleared_cannot_then_die()
{
// The reverse of the above. Together these say a terminal outcome is final, which is what
// lets both ExitDoorComponent and the console request completion without banking twice.
var attempt = new Attempt(LevelId.Of("village_1"));
attempt.TryEndInCompletion();
Assert.False(attempt.TryEndInDeath());
Assert.Equal(AttemptOutcome.Cleared, attempt.Outcome);
}
[Fact]
public void Coins_found_after_the_attempt_ended_do_not_count()
{
// The player node survives for two seconds after death while DeathScreen counts down, so a
// coin's Area2D can still fire. That is a real event rather than an error, and it must not
// enlarge a ledger that is already settled.
var attempt = new Attempt(LevelId.Of("village_1"));
attempt.RecordCoinsFound(Coins.Of(3));
attempt.RecordCoinsFound(Coins.Of(2));
attempt.TryEndInDeath();
attempt.RecordCoinsFound(Coins.Of(100));
Assert.Equal(Coins.Of(5), attempt.CoinsFound);
}
[Fact]
public void A_purchase_after_the_attempt_ended_is_refused()
{
var attempt = new Attempt(LevelId.Of("village_1"));
attempt.TryEndInDeath();
var recorded = attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.Of(1), Coins.Of(10));
Assert.False(recorded);
Assert.Equal(Coins.Zero, attempt.CoinsSpent);
Assert.Empty(attempt.SkillsBought);
}
[Fact]
public void Spending_more_than_was_found_this_attempt_is_recorded_not_rejected()
{
// The shortfall is drawn from the Bank when the Attempt is committed. Rejecting it here
// would make the ledger enforce a rule it cannot see — affordability spans both scopes.
var attempt = new Attempt(LevelId.Of("village_1"));
attempt.RecordCoinsFound(Coins.Of(10));
var recorded = attempt.TryRecordPurchase(SkillId.Of("magnetic"), SkillLevel.Of(1), Coins.Of(30));
Assert.True(recorded);
Assert.Equal(Coins.Of(10), attempt.CoinsFound);
Assert.Equal(Coins.Of(30), attempt.CoinsSpent);
}
[Fact]
public void Unlocking_then_upgrading_one_skill_records_the_level_it_ended_at()
{
// Both happen in the marketplace mid-Attempt. Commit applies one level per skill, so the
// ledger keeps where it ended up, while the costs accumulate.
var brickThrow = SkillId.Of("brick_throw");
var attempt = new Attempt(LevelId.Of("village_1"));
attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(1), Coins.Of(10));
attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(2), Coins.Of(25));
Assert.Equal(SkillLevel.Of(2), attempt.SkillsBought[brickThrow]);
Assert.Equal(Coins.Of(35), attempt.CoinsSpent);
}
}