feat: level progression moves to the new domain (slice 10b, step 1)

narrowest safe step: only level identity and progression move. Coins,
lives, skills and death stay entirely on GameStateStore and behave as
before, so nothing is tracked in two places.

GameManager addresses levels by stable LevelId via the catalogue;
LevelScenes is gone, so reordering levels no longer invalidates saves.
Every level entry goes through EnterLevel, which fixes LevelStarted never
firing on the first level or after Continue - ghost recording and splits
have been dead outside mid-campaign.

GoTo announces after a frame, once the incoming scene is actually
current. ChangeSceneToPacked is deferred, so the old code handed
GhostEventHandler the scene being torn down.

FinishLevel takes LevelTime? - GetCurrentLevelTime can legitimately be 0
and inventing a value is exactly the bad data LevelTime refuses.

Continue distinguishes absent from corrupt and refuses to overwrite a
corrupt save. Statistics move to the Profile. 69 tests.
This commit is contained in:
2026-07-28 19:57:01 +02:00
parent ca54bf0f82
commit a1c9042652
6 changed files with 260 additions and 61 deletions

View File

@@ -170,6 +170,66 @@ public class ProgressionServiceTests
Assert.Equal(LevelTime.Of(30.0), profileStore.Load().Value!.BestTimeFor(First));
}
[Fact]
public void Resuming_a_saved_playthrough_restores_where_it_had_got_to()
{
var store = new InMemoryPlaythroughStore();
var first = new ProgressionService(
Profile.Empty(), store, new InMemoryProfileStore(), TwoLevels(), Skills(), Lives.Of(3));
first.StartPlaythrough(Id);
first.EnterLevel(First);
first.FinishLevel(LevelTime.Of(20.0));
// A fresh service, as though the game had been restarted.
var resumed = new ProgressionService(
Profile.Empty(), store, new InMemoryProfileStore(), TwoLevels(), Skills(), Lives.Of(3));
Assert.True(resumed.LoadPlaythrough(Id));
Assert.Equal(Second, resumed.CurrentLevel);
Assert.True(resumed.HasCleared(First));
Assert.True(resumed.CanEnter(First));
Assert.True(resumed.CanEnter(Second));
}
[Fact]
public void Resuming_when_there_is_no_save_leaves_the_service_not_playing()
{
var service = new ProgressionService(
Profile.Empty(), new InMemoryPlaythroughStore(), new InMemoryProfileStore(),
TwoLevels(), Skills(), Lives.Of(3));
Assert.False(service.LoadPlaythrough(Id));
Assert.False(service.IsPlaying);
}
[Fact]
public void Finishing_without_a_measured_time_still_settles_the_level_but_records_no_best_time()
{
// GetCurrentLevelTime returns 0 when the speedrun timer was never started. Passing null says
// so honestly rather than banking a zero-second record.
var profileStore = new InMemoryProfileStore();
var service = new ProgressionService(
Profile.Empty(), new InMemoryPlaythroughStore(), profileStore,
TwoLevels(), Skills(), Lives.Of(3));
service.StartPlaythrough(Id);
service.EnterLevel(First);
service.FinishLevel(null);
Assert.True(service.HasCleared(First));
Assert.Equal(Second, service.CurrentLevel);
Assert.Null(profileStore.Load().Value!.BestTimeFor(First));
}
[Fact]
public void An_unreachable_level_cannot_be_entered_even_though_it_is_in_the_campaign()
{
var service = Service();
Assert.True(service.CanEnter(First));
Assert.False(service.CanEnter(Second));
}
[Fact]
public void Picking_up_a_coin_announces_the_new_total_so_the_hud_never_polls()
{