using System; 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; } /// /// 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. [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 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; 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; } /// Free unlock, as a pickup gives. Returns false if it was already owned. public bool GrantSkill(SkillId skill) { if (_service == null) return false; var granted = _service.GrantSkill(skill); Publish(); return granted; } /// Takes a Skill away — for Skills that consume themselves. public bool RevokeSkill(SkillId skill) { if (_service == null) return false; var revoked = _service.RevokeSkill(skill); Publish(); return revoked; } /// Discards the current Attempt without charging a life — leaving a Level, not dying. public void AbandonAttempt() => Run(() => _service.AbandonAttempt()); /// Console only — see . public void ApplyCheat(Func change) => Run(() => _service.ApplyCheat(change)); /// Whether there is a resumable Playthrough, for enabling the Continue button. public bool HasSavedPlaythrough => PeekPlaythrough(SoleSlot) == LoadOutcome.Loaded; private void Run(System.Action operation) { if (_service == null) return; operation(); Publish(); } private void Publish() => _republisher?.Publish(_service.DrainEvents()); #endregion }