no behaviour change - additive only. GrantSkill: pickups are free unlocks, and collecting one already owned is a no-op rather than a free upgrade. RevokeSkill: for skills that consume themselves (brick shield). AbandonAttempt: leaving a level without dying, so no life is charged. ApplyCheat rewrites the Playthrough through its snapshot rather than via SetLives/AddCoins mutators, which ADR-0005 rules out - a bare mutator is an invitation, and the old code had two paths to exactly that. Every value is re-validated on the way back in and a rejected cheat leaves the Playthrough untouched, which is asserted. 73 tests.
220 lines
7.6 KiB
C#
220 lines
7.6 KiB
C#
using System;
|
|
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; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static readonly PlaythroughId SoleSlot =
|
|
PlaythroughId.Of(new Guid("a17e0000-0000-4000-8000-000000000001"));
|
|
|
|
[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 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;
|
|
}
|
|
|
|
/// <summary>Free unlock, as a pickup gives. Returns false if it was already owned.</summary>
|
|
public bool GrantSkill(SkillId skill)
|
|
{
|
|
if (_service == null) return false;
|
|
|
|
var granted = _service.GrantSkill(skill);
|
|
Publish();
|
|
return granted;
|
|
}
|
|
|
|
/// <summary>Takes a Skill away — for Skills that consume themselves.</summary>
|
|
public bool RevokeSkill(SkillId skill)
|
|
{
|
|
if (_service == null) return false;
|
|
|
|
var revoked = _service.RevokeSkill(skill);
|
|
Publish();
|
|
return revoked;
|
|
}
|
|
|
|
/// <summary>Discards the current Attempt without charging a life — leaving a Level, not dying.</summary>
|
|
public void AbandonAttempt() => Run(() => _service.AbandonAttempt());
|
|
|
|
/// <summary>Console only — see <see cref="ProgressionService.ApplyCheat"/>.</summary>
|
|
public void ApplyCheat(Func<PlaythroughSnapshot, PlaythroughSnapshot> change) =>
|
|
Run(() => _service.ApplyCheat(change));
|
|
|
|
/// <summary>Whether there is a resumable Playthrough, for enabling the Continue button.</summary>
|
|
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
|
|
}
|