diff --git a/Autoloads/GameManager.cs b/Autoloads/GameManager.cs index 15b0dcf..06ff12c 100644 --- a/Autoloads/GameManager.cs +++ b/Autoloads/GameManager.cs @@ -1,17 +1,27 @@ using Godot; -using Godot.Collections; +using Mr.BrickAdventures.Progression; using Mr.BrickAdventures.scripts.components; namespace Mr.BrickAdventures.Autoloads; /// -/// Game orchestrator - handles scene transitions and game flow. -/// State lives in GameStateStore; query it directly for reads/writes. +/// Game orchestrator: scene transitions and game flow. /// +/// +/// +/// Which Level the player is on now belongs to , addressed by a stable +/// and resolved to a scene by the level catalogue. The old +/// Array<PackedScene> LevelScenes is gone: it made a save's meaning depend on the order +/// of an inspector array, so reordering levels silently invalidated every save. +/// +/// +/// Transitional: coins, lives and skills are still owned by GameStateStore and persisted by +/// SaveSystem. Only level progression has moved. The two are kept apart deliberately — nothing +/// is tracked in both places at once. +/// +/// public partial class GameManager : Node { - [Export] public Array LevelScenes { get; set; } = []; - public PlayerController Player { get => GetPlayer(); @@ -21,6 +31,7 @@ public partial class GameManager : Node private PlayerController _player; private SpeedRunManager SpeedRunManager => SpeedRunManager.Instance; private GameStateStore Store => GameStateStore.Instance; + private ProgressionRoot Progression => ProgressionRoot.Instance; public static GameManager Instance { get; private set; } @@ -47,70 +58,122 @@ public partial class GameManager : Node public void StartNewGame() { + if (Progression == null) return; + Store?.ResetAll(); + Progression.StartPlaythrough(ProgressionRoot.SoleSlot); SpeedRunManager?.StartTimer(); - GetTree().ChangeSceneToPacked(LevelScenes[0]); SaveSystem.Instance.SaveGame(); EventBus.EmitGameStarted(); + + GoTo(Progression.Campaign.First); } public void ContinueGame() { - var save = SaveSystem.Instance; - if (!save.LoadGame()) + if (Progression == null) return; + + // Coins and skills still come from the old save; level progression from the new one. + if (!SaveSystem.Instance.LoadGame()) + GD.PrintErr("GameManager: no legacy save to continue; starting fresh."); + + if (!Progression.LoadPlaythrough(ProgressionRoot.SoleSlot)) { - GD.PrintErr("Failed to load game. Starting a new game instead."); + var why = Progression.PeekPlaythrough(ProgressionRoot.SoleSlot); + if (why == LoadOutcome.Corrupt) + { + // Deliberately not overwritten: the file is left where it is so it can be recovered. + GD.PushError("Saved progress could not be read. It has been left untouched."); + return; + } + StartNewGame(); return; } - var idx = Store?.Player.CurrentLevel ?? 0; - if (idx < LevelScenes.Count) - { - GetTree().ChangeSceneToPacked(LevelScenes[idx]); - EventBus.EmitGameContinued(); - } - else - { - GD.PrintErr("No levels unlocked to continue."); - } + SpeedRunManager?.StartTimer(); + EventBus.EmitGameContinued(); + + GoTo(Progression.CurrentLevel ?? Progression.Campaign.First); } public void RestartGame() { + if (Progression == null) return; + Store?.ResetAll(); - GetTree().ChangeSceneToPacked(LevelScenes[0]); + Progression.StartPlaythrough(ProgressionRoot.SoleSlot); + SpeedRunManager?.StartTimer(); SaveSystem.Instance.SaveGame(); + + GoTo(Progression.Campaign.First); } public void OnLevelComplete() { - if (Store == null) return; + if (Progression?.CurrentLevel is not { } finished) return; - var levelIndex = Store.Player.CurrentLevel; - Store.MarkLevelComplete(levelIndex); - Store.CommitSessionCoins(); - Store.CommitSessionSkills(); + var seconds = SpeedRunManager?.GetCurrentLevelTime() ?? 0.0; + LevelTime? time = seconds > 0 ? LevelTime.Of(seconds) : null; - var completionTime = SpeedRunManager?.GetCurrentLevelTime() ?? 0.0; - EventBus.EmitLevelCompleted(levelIndex, GetTree().CurrentScene, completionTime); + // Old path: coins and skills earned this attempt. + Store?.CommitSessionCoins(); + Store?.CommitSessionSkills(); - Store.ResetSession(); - TryToGoToNextLevel(); + EventBus.EmitLevelCompleted(IndexOf(finished), GetTree().CurrentScene, seconds); + + // New path: banks, marks cleared, and advances campaign position if this was a first clear. + Progression.FinishLevel(time); + Store?.ResetSession(); SaveSystem.Instance.SaveGame(); + + if (Progression.CurrentLevel is { } next && !next.Equals(finished)) + GoTo(next); } - public void TryToGoToNextLevel() + /// + /// Loads a Level's scene, then announces the entry once the new scene is actually current. + /// + /// + /// The announcement is deliberately after a frame. ChangeSceneToPacked is deferred, so at + /// the moment it returns CurrentScene is still the outgoing scene — which is why the old + /// code handed GhostEventHandler the scene being torn down and ghosts were spawned into it. + /// + private async void GoTo(LevelId level) { - if (Store == null) return; - - var next = Store.Player.CurrentLevel + 1; - if (next < LevelScenes.Count && Store.IsLevelUnlocked(next)) + if (!Progression.CanEnter(level)) { - Store.Player.CurrentLevel = next; - GetTree().ChangeSceneToPacked(LevelScenes[next]); - EventBus.EmitLevelStarted(next, GetTree().CurrentScene); + GD.PushError($"Level '{level}' is not reachable in this playthrough."); + return; } + + var scene = Progression.Catalogue.SceneFor(level); + if (scene == null) + { + GD.PushError($"Level '{level}' has no loadable scene."); + return; + } + + GetTree().ChangeSceneToPacked(scene); + await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); + + Progression.EnterLevel(level); + } + + /// + /// Position in the catalogue, for the signals that are still index-based because GhostManager + /// names its files ghost_level_{index}.json. + /// + private int IndexOf(LevelId level) + { + var levels = Progression?.Catalogue?.Levels; + if (levels == null) return -1; + + for (var i = 0; i < levels.Count; i++) + if (levels[i]?.Id == level.Value) + return i; + + return -1; } public void PauseGame() diff --git a/Autoloads/ProgressionRoot.cs b/Autoloads/ProgressionRoot.cs index f9bfc95..1aa9ccc 100644 --- a/Autoloads/ProgressionRoot.cs +++ b/Autoloads/ProgressionRoot.cs @@ -1,3 +1,4 @@ +using System; using Godot; using Mr.BrickAdventures.Progression; using Mr.BrickAdventures.Progression.Adapters; @@ -23,6 +24,13 @@ public partial class ProgressionRoot : Node { public static ProgressionRoot Instance { get; private set; } + /// + /// The one slot the game currently uses. Phase B replaces this with a picker over several + /// Playthroughs; until then a fixed id keeps the file name stable across runs. + /// + public static readonly PlaythroughId SoleSlot = + PlaythroughId.Of(new Guid("a17e0000-0000-4000-8000-000000000001")); + [Export] public LevelCatalogue Catalogue { get; set; } /// Lives a new Playthrough starts with. A tuning value, so it is authored here rather than baked into the domain. @@ -109,6 +117,39 @@ public partial class ProgressionRoot : Node public void StartPlaythrough(PlaythroughId id) => Run(() => _service.StartPlaythrough(id)); + public bool LoadPlaythrough(PlaythroughId id) + { + if (_service == null) return false; + + var loaded = _service.LoadPlaythrough(id); + Publish(); + return loaded; + } + + public LoadOutcome PeekPlaythrough(PlaythroughId id) => + _service?.PeekPlaythrough(id) ?? LoadOutcome.Absent; + + public void RecordStat(StatName stat, int amount = 1) => _service?.RecordStat(stat, amount); + + #endregion + + #region Reads + + public bool IsPlaying => _service?.IsPlaying ?? false; + public bool CanEnter(LevelId level) => _service?.CanEnter(level) ?? false; + public LevelId? CurrentLevel => _service?.CurrentLevel; + public Coins TotalCoins => _service?.TotalCoins ?? Coins.Zero; + public Lives Lives => _service?.Lives ?? Lives.Of(0); + public bool IsRunOver => _service?.IsRunOver ?? false; + public SkillLevel? LevelOf(SkillId skill) => _service?.LevelOf(skill); + public bool IsOwned(SkillId skill) => _service?.IsOwned(skill) ?? false; + public bool CanAfford(SkillId skill) => _service?.CanAfford(skill) ?? false; + public bool HasCleared(LevelId level) => _service?.HasCleared(level) ?? false; + + #endregion + + #region Operations, continued + public bool EnterLevel(LevelId level) { if (_service == null) return false; @@ -122,7 +163,7 @@ public partial class ProgressionRoot : Node public void HandlePlayerDeath() => Run(() => _service.HandlePlayerDeath()); - public void FinishLevel(LevelTime time) => Run(() => _service.FinishLevel(time)); + public void FinishLevel(LevelTime? time) => Run(() => _service.FinishLevel(time)); public PurchaseOutcome PurchaseSkill(SkillId skill) { diff --git a/objects/game_manager.tscn b/objects/game_manager.tscn index 7867bcd..d32ccda 100644 --- a/objects/game_manager.tscn +++ b/objects/game_manager.tscn @@ -1,17 +1,10 @@ -[gd_scene format=3 uid="uid://b4eifkc31jsun"] +[gd_scene load_steps=3 format=3 uid="uid://b4eifkc31jsun"] [ext_resource type="Script" uid="uid://c6eoi3ymefc0x" path="res://Autoloads/GameManager.cs" id="1_t2tr6"] -[ext_resource type="PackedScene" uid="uid://dwr3avefuwnpy" path="res://scenes/level_tutorial_1.tscn" id="2_7rb6w"] -[ext_resource type="PackedScene" uid="uid://bol7g83v2accs" path="res://scenes/level_village_1.tscn" id="2_bentb"] -[ext_resource type="PackedScene" uid="uid://chqb11pfoqmeb" path="res://scenes/level_village_2.tscn" id="3_ajlkg"] -[ext_resource type="PackedScene" uid="uid://h60obxmju6mo" path="res://scenes/level_village_3.tscn" id="4_se5tb"] -[ext_resource type="PackedScene" uid="uid://bhad760x3vvco" path="res://scenes/level_village_4.tscn" id="5_mnosh"] -[ext_resource type="PackedScene" uid="uid://bbwef3n2gjkt8" path="res://scenes/level_village_5.tscn" id="6_7rb6w"] [ext_resource type="Script" uid="uid://chrhjch4ymfvr" path="res://scripts/Screenshot.cs" id="6_bbtu1"] [node name="GameManager" type="Node" unique_id=2076336659] script = ExtResource("1_t2tr6") -LevelScenes = Array[PackedScene]([ExtResource("2_7rb6w"), ExtResource("2_bentb"), ExtResource("3_ajlkg"), ExtResource("4_se5tb"), ExtResource("5_mnosh"), ExtResource("6_7rb6w")]) [node name="Screenshot" type="Node" parent="." unique_id=812485936] script = ExtResource("6_bbtu1") diff --git a/progression.tests/ProgressionServiceTests.cs b/progression.tests/ProgressionServiceTests.cs index 6e6064f..9cfacd6 100644 --- a/progression.tests/ProgressionServiceTests.cs +++ b/progression.tests/ProgressionServiceTests.cs @@ -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() { diff --git a/progression/ProgressionService.cs b/progression/ProgressionService.cs index 6e33deb..1e76a86 100644 --- a/progression/ProgressionService.cs +++ b/progression/ProgressionService.cs @@ -89,6 +89,14 @@ public sealed class ProgressionService public bool HasCleared(LevelId level) => _playthrough?.HasCleared(level) ?? false; + /// + /// Whether this Level can be entered: the Campaign's first, anything cleared, or whatever follows + /// something cleared. Separate from so a caller can check before starting + /// a scene change it would then have to undo. + /// + public bool CanEnter(LevelId level) => + _playthrough is not null && _playthrough.ReachableLevels(_campaign).Contains(level); + #endregion public void StartPlaythrough(PlaythroughId id) @@ -99,6 +107,28 @@ public sealed class ProgressionService AnnounceChanges(); } + /// + /// Resumes a stored Playthrough. Returns false when there is nothing to resume, leaving the + /// service exactly as it was — a failed resume must not clear an active Playthrough, and it must + /// not be mistaken for "start a new game", which is what overwrote saves before. + /// + public bool LoadPlaythrough(PlaythroughId id) + { + var result = _playthroughs.Load(id); + if (result.Outcome != LoadOutcome.Loaded) return false; + + _playthrough = result.Value; + _attempt = null; + AnnounceChanges(); + return true; + } + + /// Why a resume failed, for a caller that wants to tell absent from corrupt. + public LoadOutcome PeekPlaythrough(PlaythroughId id) => _playthroughs.Load(id).Outcome; + + /// Adds to a lifetime statistic on the Profile. + public void RecordStat(StatName stat, int amount = 1) => Profile.Record(stat, amount); + /// /// Begins an Attempt at and announces it. Does not touch campaign /// position — that advances only on a First Clear, so a Replay leaves it alone. @@ -168,7 +198,12 @@ public sealed class ProgressionService /// (ADR-0003), while banking and advancing happen only on a First Clear — which the Playthrough /// decides for itself from the Attempt's Level. /// - public void FinishLevel(LevelTime time) + /// + /// The completion time, or null when none was measured. Null still settles the Level — it just + /// records no Best Time. That beats inventing a value: a zero-second time is exactly the bad data + /// exists to refuse. + /// + public void FinishLevel(LevelTime? time) { if (_playthrough is null || _attempt is null) return; if (_attempt.Outcome != AttemptOutcome.InProgress) return; @@ -179,7 +214,7 @@ public sealed class ProgressionService // 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); + if (time is { } measured) Profile.RecordTime(level, measured); var wasFirstClear = _playthrough.SettleCompletion(_attempt, _campaign); _attempt = null; diff --git a/scripts/Events/StatisticsEventHandler.cs b/scripts/Events/StatisticsEventHandler.cs index 00d196d..427a66a 100644 --- a/scripts/Events/StatisticsEventHandler.cs +++ b/scripts/Events/StatisticsEventHandler.cs @@ -1,12 +1,17 @@ using Godot; using Mr.BrickAdventures.Autoloads; -using Mr.BrickAdventures.scripts.State; +using Mr.BrickAdventures.Progression; namespace Mr.BrickAdventures.scripts.Events; /// -/// Handles game events and updates statistics accordingly. +/// Records lifetime statistics on the Profile. /// +/// +/// Statistics moved to the Profile alongside level progression, because they outlive a Playthrough — +/// see docs/adr/0001-three-progression-scopes.md. is a closed set, so a typo is +/// now a compile error rather than a silently invented sixth statistic. +/// public partial class StatisticsEventHandler : Node { public override void _Ready() @@ -14,7 +19,6 @@ public partial class StatisticsEventHandler : Node EventBus.Instance.CoinCollected += OnCoinCollected; EventBus.Instance.EnemyDefeated += OnEnemyDefeated; EventBus.Instance.PlayerDied += OnPlayerDied; - EventBus.Instance.LevelCompleted += OnLevelCompleted; EventBus.Instance.ChildRescued += OnChildRescued; } @@ -25,22 +29,25 @@ public partial class StatisticsEventHandler : Node EventBus.Instance.CoinCollected -= OnCoinCollected; EventBus.Instance.EnemyDefeated -= OnEnemyDefeated; EventBus.Instance.PlayerDied -= OnPlayerDied; - EventBus.Instance.LevelCompleted -= OnLevelCompleted; EventBus.Instance.ChildRescued -= OnChildRescued; } + private static void Record(StatName stat, int amount = 1) => + ProgressionRoot.Instance?.RecordStat(stat, amount); + + // Record rejects non-positive amounts, so a zero-value coin is skipped rather than throwing. private void OnCoinCollected(int amount, Vector2 position) - => GameStateStore.Instance?.IncrementStat(StatNames.CoinsCollected, amount); + { + if (amount > 0) Record(StatName.CoinsCollected, amount); + } - private void OnEnemyDefeated(Node enemy, Vector2 position) - => GameStateStore.Instance?.IncrementStat(StatNames.EnemiesDefeated); + private void OnEnemyDefeated(Node enemy, Vector2 position) => Record(StatName.EnemiesDefeated); - private void OnPlayerDied(Vector2 position) - => GameStateStore.Instance?.IncrementStat(StatNames.Deaths); + private void OnPlayerDied(Vector2 position) => Record(StatName.Deaths); - private void OnLevelCompleted(int levelIndex, Node currentScene, double completionTime) - => GameStateStore.Instance?.IncrementStat(StatNames.LevelsCompleted); + private void OnChildRescued(Vector2 position) => Record(StatName.ChildrenRescued); - private void OnChildRescued(Vector2 position) - => GameStateStore.Instance?.IncrementStat(StatNames.ChildrenRescued); + // LevelsCompleted is recorded by ProgressionService.FinishLevel, not here. It counts completions + // including Replays — deliberately a different fact from ClearedLevels.Count — and keeping it next + // to the settlement is what stops the two drifting apart the way the old pair did. }