From 627900b2e8be7e644df3e1f91b0c6eafc191ec41 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 28 Jul 2026 19:43:04 +0200 Subject: [PATCH] feat(progression): composition root and event republisher (slice 9) ProgressionRoot autoload builds the JSON stores from GlobalizePath("user://"), loads the Profile, reads the authored upgrade tables into a SkillCatalogue, and owns the ProgressionService. Every mutating operation is wrapped so draining and republishing cannot be forgotten - the one risk of the DrainEvents design, now confined to one file. ProgressionEventRepublisher turns domain records into EventBus signals (ADR-0004), so existing subscribers and scene wiring keep working. New RunEnded signal: GameOverScreen reads Lives directly today, which is the last-life soft-lock. Registered after SkillManager, since the skill catalogue is built from AvailableSkills. GameStateStore stays until slice 10 moves the callers. Transitional: LevelStarted is republished with a catalogue index because GhostManager names files ghost_level_{index}.json. --- Autoloads/EventBus.cs | 10 ++ Autoloads/ProgressionEventRepublisher.cs | 72 +++++++++++ Autoloads/ProgressionRoot.cs | 147 +++++++++++++++++++++++ objects/progression_root.tscn | 9 ++ project.godot | 1 + 5 files changed, 239 insertions(+) create mode 100644 Autoloads/ProgressionEventRepublisher.cs create mode 100644 Autoloads/ProgressionRoot.cs create mode 100644 objects/progression_root.tscn diff --git a/Autoloads/EventBus.cs b/Autoloads/EventBus.cs index bb24e52..4266885 100644 --- a/Autoloads/EventBus.cs +++ b/Autoloads/EventBus.cs @@ -154,6 +154,16 @@ public partial class EventBus : Node [Signal] public delegate void CoinsChangedEventHandler(int totalCoins); [Signal] public delegate void LivesChangedEventHandler(int lives); + /// + /// The Playthrough has no lives left. Raised as part of the same operation that spends the last + /// life, so a subscriber can never observe lives that have not been decremented yet — which is + /// the race that lets the death screen sit there forever on the last life. + /// + [Signal] public delegate void RunEndedEventHandler(); + + public static void EmitRunEnded() + => Instance?.EmitSignal(SignalName.RunEnded); + public static void EmitCoinsChanged(int totalCoins) => Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins); diff --git a/Autoloads/ProgressionEventRepublisher.cs b/Autoloads/ProgressionEventRepublisher.cs new file mode 100644 index 0000000..5868093 --- /dev/null +++ b/Autoloads/ProgressionEventRepublisher.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using Godot; +using Mr.BrickAdventures.Progression; +using Mr.BrickAdventures.scripts.Resources; + +namespace Mr.BrickAdventures.Autoloads; + +/// +/// Translates domain events into Godot signals on the . +/// +/// +/// +/// The adapter half of ADR-0004. Aggregates raise plain records with no engine dependency; this is the +/// single place that turns them into signals, which is what makes "state changed ⇒ signal fired" +/// un-bypassable. Existing subscribers and scene wiring keep working unchanged. +/// +/// +/// Not a Node — it has no lifecycle of its own. The composition root owns one and hands it every +/// batch of drained events. +/// +/// +public sealed class ProgressionEventRepublisher +{ + private readonly LevelCatalogue _catalogue; + private readonly SceneTree _tree; + + public ProgressionEventRepublisher(LevelCatalogue catalogue, SceneTree tree) + { + _catalogue = catalogue; + _tree = tree; + } + + public void Publish(IReadOnlyList events) + { + foreach (var @event in events) + Publish(@event); + } + + private void Publish(ProgressionEvent @event) + { + switch (@event) + { + case LevelStarted started: + // The existing signal is keyed by array index because GhostManager names its replay + // files ghost_level_{index}.json. Transitional: once ghosts key on LevelId this + // becomes a straight pass-through and the index lookup goes away. + EventBus.EmitLevelStarted(IndexOf(started.Level), _tree.CurrentScene); + break; + + case RunEnded: + EventBus.EmitRunEnded(); + break; + + case AchievementUnlocked unlocked: + EventBus.EmitAchievementUnlocked(unlocked.Achievement.Value); + break; + + default: + GD.PushWarning($"No signal is published for domain event {@event.GetType().Name}."); + break; + } + } + + private int IndexOf(LevelId level) + { + for (var i = 0; i < _catalogue.Levels.Count; i++) + if (_catalogue.Levels[i]?.Id == level.Value) + return i; + + return -1; + } +} diff --git a/Autoloads/ProgressionRoot.cs b/Autoloads/ProgressionRoot.cs new file mode 100644 index 0000000..f9bfc95 --- /dev/null +++ b/Autoloads/ProgressionRoot.cs @@ -0,0 +1,147 @@ +using Godot; +using Mr.BrickAdventures.Progression; +using Mr.BrickAdventures.Progression.Adapters; +using Mr.BrickAdventures.scripts.Resources; + +namespace Mr.BrickAdventures.Autoloads; + +/// +/// Composition root for player progression: builds the stores, loads the Profile, and owns the +/// . +/// +/// +/// +/// The only Node in this area. The aggregates are plain objects, so their lifetime is no longer tied +/// to the scene tree — which is why the operations below need no null-guarding of the store itself. +/// +/// +/// Every mutating operation is wrapped so that draining and republishing cannot be forgotten. That is +/// the one risk of the DrainEvents design, and wrapping collapses it to this file. +/// +/// +public partial class ProgressionRoot : Node +{ + public static ProgressionRoot Instance { get; private set; } + + [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. + [Export] public int StartingLives { get; set; } = 3; + + private ProgressionService _service; + private ProgressionEventRepublisher _republisher; + + /// Levels in campaign order, as the domain sees them. + public Campaign Campaign { get; private set; } + + public override void _Ready() + { + Instance = this; + + if (Catalogue == null) + { + GD.PushError("ProgressionRoot has no LevelCatalogue assigned; progression is unavailable."); + return; + } + + foreach (var problem in Catalogue.Validate()) + GD.PushError($"LevelCatalogue: {problem}"); + + Campaign = Catalogue.ToCampaign(); + + var directory = ProjectSettings.GlobalizePath("user://"); + var playthroughs = new JsonPlaythroughStore(directory); + var profiles = new JsonProfileStore(directory); + + _service = new ProgressionService( + LoadProfile(profiles), playthroughs, profiles, + Campaign, BuildSkillCatalogue(), Lives.Of(StartingLives)); + + _republisher = new ProgressionEventRepublisher(Catalogue, GetTree()); + } + + public override void _ExitTree() + { + if (Instance == this) Instance = null; + } + + /// + /// A missing Profile is a first run. A corrupt one is reported loudly and replaced with an empty + /// Profile so the game still starts — but the file is left alone, so it can still be recovered. + /// + private static Profile LoadProfile(IProfileStore profiles) + { + var result = profiles.Load(); + + if (result.Outcome == LoadOutcome.Corrupt) + GD.PushError($"Profile could not be read and has been left untouched: {result.Problem}"); + + return result.Outcome == LoadOutcome.Loaded ? result.Value : Profile.Empty(); + } + + /// + /// Reads the authored upgrade tables into the domain's view of them. A Skill with no upgrade + /// entries yields an empty cost list, which the domain reports as not purchasable rather than + /// throwing on Upgrades[0]. + /// + private static SkillCatalogue BuildSkillCatalogue() + { + var available = SkillManager.Instance?.AvailableSkills; + if (available == null) return SkillCatalogue.Empty(); + + var costs = new System.Collections.Generic.Dictionary>(); + + foreach (var skill in available) + { + if (skill == null || string.IsNullOrWhiteSpace(skill.Name)) continue; + + var levels = new System.Collections.Generic.List(); + foreach (var upgrade in skill.Upgrades) + levels.Add(Coins.Of(Mathf.Max(0, upgrade.Cost))); + + costs[SkillId.Of(skill.Name)] = levels; + } + + return SkillCatalogue.Of(costs); + } + + #region Operations — each drains and republishes, so callers cannot forget to + + public void StartPlaythrough(PlaythroughId id) => Run(() => _service.StartPlaythrough(id)); + + public bool EnterLevel(LevelId level) + { + if (_service == null) return false; + + var entered = _service.EnterLevel(level); + Publish(); + return entered; + } + + public void RecordCoinsFound(Coins amount) => Run(() => _service.RecordCoinsFound(amount)); + + public void HandlePlayerDeath() => Run(() => _service.HandlePlayerDeath()); + + public void FinishLevel(LevelTime time) => Run(() => _service.FinishLevel(time)); + + public PurchaseOutcome PurchaseSkill(SkillId skill) + { + if (_service == null) return PurchaseOutcome.NotPlaying; + + var outcome = _service.PurchaseSkill(skill); + Publish(); + return outcome; + } + + private void Run(System.Action operation) + { + if (_service == null) return; + + operation(); + Publish(); + } + + private void Publish() => _republisher?.Publish(_service.DrainEvents()); + + #endregion +} diff --git a/objects/progression_root.tscn b/objects/progression_root.tscn new file mode 100644 index 0000000..a734334 --- /dev/null +++ b/objects/progression_root.tscn @@ -0,0 +1,9 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://Autoloads/ProgressionRoot.cs" id="1_script"] +[ext_resource type="Resource" path="res://resources/levels/level_catalogue.tres" id="2_catalogue"] + +[node name="ProgressionRoot" type="Node"] +script = ExtResource("1_script") +Catalogue = ExtResource("2_catalogue") +StartingLives = 3 diff --git a/project.godot b/project.godot index 3c9be25..5f9324a 100644 --- a/project.godot +++ b/project.godot @@ -54,6 +54,7 @@ StatisticsEventHandler="*res://scripts/Events/StatisticsEventHandler.cs" CoinStateHandler="*res://scripts/Events/CoinStateHandler.cs" LivesStateHandler="*res://scripts/Events/LivesStateHandler.cs" SkillCollectHandler="*res://scripts/Events/SkillCollectHandler.cs" +ProgressionRoot="*res://objects/progression_root.tscn" GameStateStore="*res://Autoloads/GameStateStore.cs" GameManager="*res://objects/game_manager.tscn"