Files
przygody-pana-cegly/Autoloads/ProgressionRoot.cs
Gabriel Kaszewski 627900b2e8 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.
2026-07-28 19:43:04 +02:00

148 lines
5.0 KiB
C#

using Godot;
using Mr.BrickAdventures.Progression;
using Mr.BrickAdventures.Progression.Adapters;
using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Composition root for player progression: builds the stores, loads the Profile, and owns the
/// <see cref="ProgressionService"/>.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public partial class ProgressionRoot : Node
{
public static ProgressionRoot Instance { get; private set; }
[Export] public LevelCatalogue Catalogue { get; set; }
/// <summary>Lives a new Playthrough starts with. A tuning value, so it is authored here rather than baked into the domain.</summary>
[Export] public int StartingLives { get; set; } = 3;
private ProgressionService _service;
private ProgressionEventRepublisher _republisher;
/// <summary>Levels in campaign order, as the domain sees them.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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 <c>Upgrades[0]</c>.
/// </summary>
private static SkillCatalogue BuildSkillCatalogue()
{
var available = SkillManager.Instance?.AvailableSkills;
if (available == null) return SkillCatalogue.Empty();
var costs = new System.Collections.Generic.Dictionary<SkillId, System.Collections.Generic.IReadOnlyList<Coins>>();
foreach (var skill in available)
{
if (skill == null || string.IsNullOrWhiteSpace(skill.Name)) continue;
var levels = new System.Collections.Generic.List<Coins>();
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
}