Compare commits
21 Commits
master
...
phase-a-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e6d5cf6f9 | |||
| e8d1da8f89 | |||
| 360f73ba24 | |||
| b6a8448743 | |||
| f4411e05a3 | |||
| a1c9042652 | |||
| ca54bf0f82 | |||
| c1c3e7b020 | |||
| 627900b2e8 | |||
| f85c16c641 | |||
| 7a69292066 | |||
| f991bb99fc | |||
| 330e10880c | |||
| 003ec4b010 | |||
| 05149bb4a9 | |||
| ffa2b986ae | |||
| 40bd5c1d86 | |||
| 9767941c65 | |||
| 313a5315b1 | |||
| c3bbf3282a | |||
| dd113af786 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -3,3 +3,11 @@
|
|||||||
/android/
|
/android/
|
||||||
builds/
|
builds/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# IDE / local tooling
|
||||||
|
.idea/
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
|
# .NET build output
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
|||||||
@@ -154,6 +154,36 @@ public partial class EventBus : Node
|
|||||||
[Signal] public delegate void CoinsChangedEventHandler(int totalCoins);
|
[Signal] public delegate void CoinsChangedEventHandler(int totalCoins);
|
||||||
[Signal] public delegate void LivesChangedEventHandler(int lives);
|
[Signal] public delegate void LivesChangedEventHandler(int lives);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[Signal] public delegate void RunEndedEventHandler();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A death has been settled. The life is already spent and the attempt already discarded, so
|
||||||
|
/// <paramref name="remainingLives"/> is safe to display directly — unlike reading Lives from a
|
||||||
|
/// handler racing the one that writes it.
|
||||||
|
/// </summary>
|
||||||
|
[Signal] public delegate void AttemptFailedEventHandler(int remainingLives, bool runOver);
|
||||||
|
|
||||||
|
public static void EmitAttemptFailed(int remainingLives, bool runOver)
|
||||||
|
=> Instance?.EmitSignal(SignalName.AttemptFailed, remainingLives, runOver);
|
||||||
|
|
||||||
|
public static void EmitRunEnded()
|
||||||
|
=> Instance?.EmitSignal(SignalName.RunEnded);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The set of owned skills, or the level of one of them, changed. SkillManager reconciles its live
|
||||||
|
/// components against this instead of every caller remembering to update it separately — the
|
||||||
|
/// non-atomic pair of calls that could leave a skill owned but inert.
|
||||||
|
/// </summary>
|
||||||
|
[Signal] public delegate void SkillSetChangedEventHandler();
|
||||||
|
|
||||||
|
public static void EmitSkillSetChanged()
|
||||||
|
=> Instance?.EmitSignal(SignalName.SkillSetChanged);
|
||||||
|
|
||||||
public static void EmitCoinsChanged(int totalCoins)
|
public static void EmitCoinsChanged(int totalCoins)
|
||||||
=> Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins);
|
=> Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins);
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
using Godot;
|
using Godot;
|
||||||
using Godot.Collections;
|
using Mr.BrickAdventures.Progression;
|
||||||
using Mr.BrickAdventures.scripts.components;
|
using Mr.BrickAdventures.scripts.components;
|
||||||
|
|
||||||
namespace Mr.BrickAdventures.Autoloads;
|
namespace Mr.BrickAdventures.Autoloads;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Game orchestrator - handles scene transitions and game flow.
|
/// Game orchestrator: scene transitions and game flow.
|
||||||
/// State lives in GameStateStore; query it directly for reads/writes.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Which Level the player is on now belongs to <see cref="ProgressionRoot"/>, addressed by a stable
|
||||||
|
/// <see cref="LevelId"/> and resolved to a scene by the level catalogue. The old
|
||||||
|
/// <c>Array<PackedScene> LevelScenes</c> is gone: it made a save's meaning depend on the order
|
||||||
|
/// of an inspector array, so reordering levels silently invalidated every save.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Progression is persisted by <see cref="ProgressionRoot"/>'s stores, which save themselves at the
|
||||||
|
/// points that matter — a First Clear and a death — so there is no separate "save the game" step to
|
||||||
|
/// forget.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
public partial class GameManager : Node
|
public partial class GameManager : Node
|
||||||
{
|
{
|
||||||
[Export] public Array<PackedScene> LevelScenes { get; set; } = [];
|
|
||||||
|
|
||||||
public PlayerController Player
|
public PlayerController Player
|
||||||
{
|
{
|
||||||
get => GetPlayer();
|
get => GetPlayer();
|
||||||
@@ -20,7 +30,7 @@ public partial class GameManager : Node
|
|||||||
|
|
||||||
private PlayerController _player;
|
private PlayerController _player;
|
||||||
private SpeedRunManager SpeedRunManager => SpeedRunManager.Instance;
|
private SpeedRunManager SpeedRunManager => SpeedRunManager.Instance;
|
||||||
private GameStateStore Store => GameStateStore.Instance;
|
private ProgressionRoot Progression => ProgressionRoot.Instance;
|
||||||
|
|
||||||
public static GameManager Instance { get; private set; }
|
public static GameManager Instance { get; private set; }
|
||||||
|
|
||||||
@@ -47,70 +57,109 @@ public partial class GameManager : Node
|
|||||||
|
|
||||||
public void StartNewGame()
|
public void StartNewGame()
|
||||||
{
|
{
|
||||||
Store?.ResetAll();
|
if (Progression == null) return;
|
||||||
|
|
||||||
|
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
|
||||||
SpeedRunManager?.StartTimer();
|
SpeedRunManager?.StartTimer();
|
||||||
GetTree().ChangeSceneToPacked(LevelScenes[0]);
|
|
||||||
SaveSystem.Instance.SaveGame();
|
|
||||||
EventBus.EmitGameStarted();
|
EventBus.EmitGameStarted();
|
||||||
|
|
||||||
|
GoTo(Progression.Campaign.First);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ContinueGame()
|
public void ContinueGame()
|
||||||
{
|
{
|
||||||
var save = SaveSystem.Instance;
|
if (Progression == null) return;
|
||||||
if (!save.LoadGame())
|
|
||||||
|
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();
|
StartNewGame();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var idx = Store?.Player.CurrentLevel ?? 0;
|
SpeedRunManager?.StartTimer();
|
||||||
if (idx < LevelScenes.Count)
|
EventBus.EmitGameContinued();
|
||||||
{
|
|
||||||
GetTree().ChangeSceneToPacked(LevelScenes[idx]);
|
GoTo(Progression.CurrentLevel ?? Progression.Campaign.First);
|
||||||
EventBus.EmitGameContinued();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
GD.PrintErr("No levels unlocked to continue.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RestartGame()
|
public void RestartGame()
|
||||||
{
|
{
|
||||||
Store?.ResetAll();
|
if (Progression == null) return;
|
||||||
GetTree().ChangeSceneToPacked(LevelScenes[0]);
|
|
||||||
SaveSystem.Instance.SaveGame();
|
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
|
||||||
|
SpeedRunManager?.StartTimer();
|
||||||
|
|
||||||
|
GoTo(Progression.Campaign.First);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnLevelComplete()
|
public void OnLevelComplete()
|
||||||
{
|
{
|
||||||
if (Store == null) return;
|
if (Progression?.CurrentLevel is not { } finished) return;
|
||||||
|
|
||||||
var levelIndex = Store.Player.CurrentLevel;
|
var seconds = SpeedRunManager?.GetCurrentLevelTime() ?? 0.0;
|
||||||
Store.MarkLevelComplete(levelIndex);
|
LevelTime? time = seconds > 0 ? LevelTime.Of(seconds) : null;
|
||||||
Store.CommitSessionCoins();
|
|
||||||
Store.CommitSessionSkills();
|
|
||||||
|
|
||||||
var completionTime = SpeedRunManager?.GetCurrentLevelTime() ?? 0.0;
|
EventBus.EmitLevelCompleted(IndexOf(finished), GetTree().CurrentScene, seconds);
|
||||||
EventBus.EmitLevelCompleted(levelIndex, GetTree().CurrentScene, completionTime);
|
|
||||||
|
|
||||||
Store.ResetSession();
|
// Banks the attempt's coins and skills, marks the level cleared, and advances campaign
|
||||||
TryToGoToNextLevel();
|
// position if this was a First Clear.
|
||||||
SaveSystem.Instance.SaveGame();
|
Progression.FinishLevel(time);
|
||||||
|
|
||||||
|
if (Progression.CurrentLevel is { } next && !next.Equals(finished))
|
||||||
|
GoTo(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void TryToGoToNextLevel()
|
/// <summary>
|
||||||
|
/// Loads a Level's scene, then announces the entry once the new scene is actually current.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The announcement is deliberately after a frame. <c>ChangeSceneToPacked</c> is deferred, so at
|
||||||
|
/// the moment it returns <c>CurrentScene</c> is still the outgoing scene — which is why the old
|
||||||
|
/// code handed <c>GhostEventHandler</c> the scene being torn down and ghosts were spawned into it.
|
||||||
|
/// </remarks>
|
||||||
|
private async void GoTo(LevelId level)
|
||||||
{
|
{
|
||||||
if (Store == null) return;
|
if (!Progression.CanEnter(level))
|
||||||
|
|
||||||
var next = Store.Player.CurrentLevel + 1;
|
|
||||||
if (next < LevelScenes.Count && Store.IsLevelUnlocked(next))
|
|
||||||
{
|
{
|
||||||
Store.Player.CurrentLevel = next;
|
GD.PushError($"Level '{level}' is not reachable in this playthrough.");
|
||||||
GetTree().ChangeSceneToPacked(LevelScenes[next]);
|
return;
|
||||||
EventBus.EmitLevelStarted(next, GetTree().CurrentScene);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Position in the catalogue, for the signals that are still index-based because GhostManager
|
||||||
|
/// names its files <c>ghost_level_{index}.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
public void PauseGame()
|
||||||
|
|||||||
88
Autoloads/ProgressionEventRepublisher.cs
Normal file
88
Autoloads/ProgressionEventRepublisher.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
using Mr.BrickAdventures.Progression;
|
||||||
|
using Mr.BrickAdventures.scripts.Resources;
|
||||||
|
|
||||||
|
namespace Mr.BrickAdventures.Autoloads;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Translates domain events into Godot signals on the <see cref="EventBus"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Not a Node — it has no lifecycle of its own. The composition root owns one and hands it every
|
||||||
|
/// batch of drained events.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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<ProgressionEvent> 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 AttemptFailed failed:
|
||||||
|
EventBus.EmitAttemptFailed(failed.Remaining.Value, failed.RunOver);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AchievementUnlocked unlocked:
|
||||||
|
EventBus.EmitAchievementUnlocked(unlocked.Achievement.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LivesChanged lives:
|
||||||
|
EventBus.EmitLivesChanged(lives.Remaining.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TotalCoinsChanged coins:
|
||||||
|
EventBus.EmitCoinsChanged(coins.Total.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SkillSetChanged:
|
||||||
|
EventBus.EmitSkillSetChanged();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Autoloads/ProgressionEventRepublisher.cs.uid
Normal file
1
Autoloads/ProgressionEventRepublisher.cs.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cjm0g50la7uie
|
||||||
219
Autoloads/ProgressionRoot.cs
Normal file
219
Autoloads/ProgressionRoot.cs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
1
Autoloads/ProgressionRoot.cs.uid
Normal file
1
Autoloads/ProgressionRoot.cs.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b6amqp58yjfc4
|
||||||
@@ -1,22 +1,40 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Godot;
|
using Godot;
|
||||||
using Godot.Collections;
|
using Godot.Collections;
|
||||||
|
using Mr.BrickAdventures.Progression;
|
||||||
using Mr.BrickAdventures.scripts.components;
|
using Mr.BrickAdventures.scripts.components;
|
||||||
using Mr.BrickAdventures.scripts.interfaces;
|
using Mr.BrickAdventures.scripts.interfaces;
|
||||||
using Mr.BrickAdventures.scripts.Resources;
|
using Mr.BrickAdventures.scripts.Resources;
|
||||||
|
|
||||||
namespace Mr.BrickAdventures.Autoloads;
|
namespace Mr.BrickAdventures.Autoloads;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the live skill components attached to the player, and keeps them matching what the Playthrough
|
||||||
|
/// says is owned.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Reconciles from <see cref="EventBus.SkillSetChanged"/> rather than being told by each caller. The old
|
||||||
|
/// arrangement had four call sites doing "record it in the store, then tell SkillManager" as two
|
||||||
|
/// separate calls — anything between them, including <c>AddSkill</c>'s own early return when no player
|
||||||
|
/// is registered, left a skill owned but inert.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Tracks the level each component was built at, so an upgrade rebuilds it. Previously
|
||||||
|
/// <c>AddSkill</c> returned early for an already-active skill, and the upgrade was applied by the
|
||||||
|
/// purchase code reaching into the component instead.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
public partial class SkillManager : Node
|
public partial class SkillManager : Node
|
||||||
{
|
{
|
||||||
private PlayerController _player;
|
private PlayerController _player;
|
||||||
private GameStateStore Store => GameStateStore.Instance;
|
|
||||||
|
|
||||||
[Export] public Array<SkillData> AvailableSkills { get; set; } = [];
|
[Export] public Array<SkillData> AvailableSkills { get; set; } = [];
|
||||||
|
|
||||||
public Dictionary ActiveComponents { get; private set; } = new();
|
public Dictionary ActiveComponents { get; private set; } = new();
|
||||||
|
|
||||||
|
private readonly System.Collections.Generic.Dictionary<string, int> _activeLevels = new();
|
||||||
|
|
||||||
public static SkillManager Instance { get; private set; }
|
public static SkillManager Instance { get; private set; }
|
||||||
|
|
||||||
[Signal]
|
[Signal]
|
||||||
@@ -27,10 +45,14 @@ public partial class SkillManager : Node
|
|||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
Instance = this;
|
Instance = this;
|
||||||
|
EventBus.Instance.SkillSetChanged += Reconcile;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void _ExitTree()
|
public override void _ExitTree()
|
||||||
{
|
{
|
||||||
|
if (EventBus.Instance != null)
|
||||||
|
EventBus.Instance.SkillSetChanged -= Reconcile;
|
||||||
|
|
||||||
if (Instance == this) Instance = null;
|
if (Instance == this) Instance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,24 +63,17 @@ public partial class SkillManager : Node
|
|||||||
{
|
{
|
||||||
if (_player == player) return;
|
if (_player == player) return;
|
||||||
|
|
||||||
// If a player is already registered (e.g., from a previous scene), unregister it first.
|
|
||||||
if (_player != null && IsInstanceValid(_player))
|
if (_player != null && IsInstanceValid(_player))
|
||||||
{
|
|
||||||
UnregisterPlayer();
|
UnregisterPlayer();
|
||||||
}
|
|
||||||
|
|
||||||
_player = player;
|
_player = player;
|
||||||
if (_player != null)
|
if (_player != null)
|
||||||
{
|
{
|
||||||
// Automatically unregister when the player node is removed from the scene.
|
|
||||||
_player.TreeExiting += UnregisterPlayer;
|
_player.TreeExiting += UnregisterPlayer;
|
||||||
ApplyUnlockedSkills();
|
Reconcile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cleans up skills and references related to the current player.
|
|
||||||
/// </summary>
|
|
||||||
private void UnregisterPlayer()
|
private void UnregisterPlayer()
|
||||||
{
|
{
|
||||||
if (_player != null && IsInstanceValid(_player))
|
if (_player != null && IsInstanceValid(_player))
|
||||||
@@ -69,9 +84,37 @@ public partial class SkillManager : Node
|
|||||||
_player = null;
|
_player = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddSkill(SkillData skillData)
|
/// <summary>
|
||||||
|
/// Brings the live components in line with what is owned: adds what is missing, rebuilds anything
|
||||||
|
/// whose level changed, removes what is no longer owned.
|
||||||
|
/// </summary>
|
||||||
|
public void Reconcile()
|
||||||
|
{
|
||||||
|
if (_player == null || !IsInstanceValid(_player)) return;
|
||||||
|
|
||||||
|
var progression = ProgressionRoot.Instance;
|
||||||
|
if (progression == null) return;
|
||||||
|
|
||||||
|
foreach (var skill in AvailableSkills)
|
||||||
|
{
|
||||||
|
if (skill == null || string.IsNullOrWhiteSpace(skill.Name)) continue;
|
||||||
|
|
||||||
|
var owned = progression.LevelOf(SkillId.Of(skill.Name))?.Value;
|
||||||
|
var active = _activeLevels.TryGetValue(skill.Name, out var at) ? at : (int?)null;
|
||||||
|
|
||||||
|
if (owned == active) continue;
|
||||||
|
|
||||||
|
if (active is not null) RemoveSkill(skill.Name);
|
||||||
|
if (owned is not null) AddSkill(skill, owned.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Kept for the marketplace's activate/deactivate toggle, which is a display concern.</summary>
|
||||||
|
public void AddSkill(SkillData skillData) =>
|
||||||
|
AddSkill(skillData, ProgressionRoot.Instance?.LevelOf(SkillId.Of(skillData.Name))?.Value ?? 1);
|
||||||
|
|
||||||
|
private void AddSkill(SkillData skillData, int level)
|
||||||
{
|
{
|
||||||
// Ensure a valid player is registered before adding a skill.
|
|
||||||
if (_player == null || !IsInstanceValid(_player))
|
if (_player == null || !IsInstanceValid(_player))
|
||||||
{
|
{
|
||||||
GD.Print("SkillManager: Player not available to add skill.");
|
GD.Print("SkillManager: Player not available to add skill.");
|
||||||
@@ -83,10 +126,9 @@ public partial class SkillManager : Node
|
|||||||
|
|
||||||
if (skillData.Type == SkillType.Throw)
|
if (skillData.Type == SkillType.Throw)
|
||||||
{
|
{
|
||||||
// Remove any other active throw skill before adding the new one
|
|
||||||
foreach (var sd in AvailableSkills)
|
foreach (var sd in AvailableSkills)
|
||||||
{
|
{
|
||||||
if (sd != skillData && sd.Type == SkillType.Throw)
|
if (sd != skillData && sd?.Type == SkillType.Throw)
|
||||||
RemoveSkill(sd.Name);
|
RemoveSkill(sd.Name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,8 +136,7 @@ public partial class SkillManager : Node
|
|||||||
var instance = skillData.Node.Instantiate();
|
var instance = skillData.Node.Instantiate();
|
||||||
if (instance is ISkill skill)
|
if (instance is ISkill skill)
|
||||||
{
|
{
|
||||||
// Initialize the skill with the registered player instance.
|
skill.Initialize(_player, skillData, level);
|
||||||
skill.Initialize(_player, skillData);
|
|
||||||
skill.Activate();
|
skill.Activate();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -105,31 +146,27 @@ public partial class SkillManager : Node
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the skill node as a child of the player.
|
|
||||||
_player.AddChild(instance);
|
_player.AddChild(instance);
|
||||||
ActiveComponents[skillData.Name] = instance;
|
ActiveComponents[skillData.Name] = instance;
|
||||||
|
_activeLevels[skillData.Name] = level;
|
||||||
|
|
||||||
if (instance is BrickThrowComponent btc)
|
if (instance is BrickThrowComponent btc)
|
||||||
{
|
|
||||||
EmitSignalActiveThrowSkillChanged(btc);
|
EmitSignalActiveThrowSkillChanged(btc);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RemoveSkill(string skillName)
|
public void RemoveSkill(string skillName)
|
||||||
{
|
{
|
||||||
|
_activeLevels.Remove(skillName);
|
||||||
|
|
||||||
if (!ActiveComponents.TryGetValue(skillName, out var component))
|
if (!ActiveComponents.TryGetValue(skillName, out var component))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (component.AsGodotObject() is BrickThrowComponent)
|
if (component.AsGodotObject() is BrickThrowComponent)
|
||||||
{
|
|
||||||
EmitSignalActiveThrowSkillChanged(null);
|
EmitSignalActiveThrowSkillChanged(null);
|
||||||
}
|
|
||||||
|
|
||||||
var inst = (Node)component;
|
var inst = (Node)component;
|
||||||
if (inst is ISkill skill)
|
if (inst is ISkill skill)
|
||||||
{
|
|
||||||
skill.Deactivate();
|
skill.Deactivate();
|
||||||
}
|
|
||||||
|
|
||||||
if (IsInstanceValid(inst))
|
if (IsInstanceValid(inst))
|
||||||
inst.QueueFree();
|
inst.QueueFree();
|
||||||
@@ -141,56 +178,30 @@ public partial class SkillManager : Node
|
|||||||
|
|
||||||
private void RemoveAllActiveSkills()
|
private void RemoveAllActiveSkills()
|
||||||
{
|
{
|
||||||
// Create a copy of keys to avoid modification during iteration
|
foreach (var skillName in ActiveComponents.Keys.Select(k => k.ToString()).ToList())
|
||||||
var keys = ActiveComponents.Keys.ToArray();
|
|
||||||
var skillNames = keys.Select(key => key.ToString()).ToList();
|
|
||||||
|
|
||||||
foreach (var skillName in skillNames)
|
|
||||||
{
|
|
||||||
RemoveSkill(skillName);
|
RemoveSkill(skillName);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ApplyUnlockedSkills()
|
|
||||||
{
|
|
||||||
if (_player == null || !IsInstanceValid(_player)) return;
|
|
||||||
if (Store == null) return;
|
|
||||||
|
|
||||||
foreach (var sd in AvailableSkills)
|
|
||||||
{
|
|
||||||
if (Store.IsSkillUnlocked(sd))
|
|
||||||
CallDeferred(MethodName.AddSkill, sd);
|
|
||||||
else
|
|
||||||
RemoveSkill(sd.Name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public SkillData GetSkillByName(string skillName)
|
public SkillData GetSkillByName(string skillName)
|
||||||
{
|
{
|
||||||
foreach (var sd in AvailableSkills)
|
foreach (var sd in AvailableSkills)
|
||||||
if (sd.Name == skillName) return sd;
|
if (sd?.Name == skillName) return sd;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsSkillActive(SkillData skill)
|
public bool IsSkillActive(SkillData skill) =>
|
||||||
{
|
skill != null && ActiveComponents.ContainsKey(skill.Name);
|
||||||
return skill != null && ActiveComponents.ContainsKey(skill.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ActivateSkill(SkillData skill)
|
public void ActivateSkill(SkillData skill)
|
||||||
{
|
{
|
||||||
if (!ActiveComponents.ContainsKey(skill.Name))
|
if (!ActiveComponents.ContainsKey(skill.Name))
|
||||||
{
|
|
||||||
AddSkill(skill);
|
AddSkill(skill);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DeactivateSkill(SkillData skill)
|
public void DeactivateSkill(SkillData skill)
|
||||||
{
|
{
|
||||||
if (ActiveComponents.ContainsKey(skill.Name))
|
if (ActiveComponents.ContainsKey(skill.Name))
|
||||||
{
|
|
||||||
RemoveSkill(skill.Name);
|
RemoveSkill(skill.Name);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ToggleSkillActivation(SkillData skill)
|
public void ToggleSkillActivation(SkillData skill)
|
||||||
@@ -202,4 +213,4 @@ public partial class SkillManager : Node
|
|||||||
else
|
else
|
||||||
ActivateSkill(skill);
|
ActivateSkill(skill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
103
CONTEXT.md
Normal file
103
CONTEXT.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Mr. Brick Adventures
|
||||||
|
|
||||||
|
A 2D platformer. This context covers **player state and progression** — what the player has, what they have done,
|
||||||
|
and at what point each of those becomes permanent. Gameplay behaviour (movement, combat, hazards) lives in the
|
||||||
|
composable components and is deliberately outside this context.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
### Scopes
|
||||||
|
|
||||||
|
Three lifetimes. Every fact in this context belongs to exactly one.
|
||||||
|
|
||||||
|
**Attempt**:
|
||||||
|
One try at a level, from entering it to either completing it or losing a life. Holds a ledger of everything that
|
||||||
|
happened during the try — coins found, coins spent, skills bought. Discarded whole if the player dies.
|
||||||
|
_Avoid_: Session, run, life
|
||||||
|
|
||||||
|
**Playthrough**:
|
||||||
|
One game from New Game until game over, identified by a `PlaythroughId`. Holds lives, banked coins, owned skills
|
||||||
|
and their levels, campaign position, and which levels it has cleared. Several exist at once and the player picks
|
||||||
|
between them.
|
||||||
|
_Avoid_: Save, save slot, progress, game, run
|
||||||
|
|
||||||
|
**Profile**:
|
||||||
|
Everything shared across all Playthroughs, one per installation. Achievements, best time per Level, and lifetime
|
||||||
|
statistics. Never reset by starting a new Playthrough.
|
||||||
|
_Avoid_: Global state, meta, persistent data, account
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
|
||||||
|
**Settle**:
|
||||||
|
Resolving an ended Attempt against the Playthrough — Commit it on a First Clear, Discard it otherwise. Settling is
|
||||||
|
the only way an Attempt affects a Playthrough, and it decides First Clear versus Replay itself rather than being
|
||||||
|
told which one happened.
|
||||||
|
_Avoid_: Apply, resolve, finalise
|
||||||
|
|
||||||
|
**Commit**:
|
||||||
|
Applying an Attempt's ledger to the Playthrough. Only a First Clear commits — nothing else, ever.
|
||||||
|
_Avoid_: Save, persist, flush, sync
|
||||||
|
|
||||||
|
**First Clear**:
|
||||||
|
Completing a Level this Playthrough has not cleared before. The only event that commits an Attempt and advances
|
||||||
|
campaign position.
|
||||||
|
_Avoid_: Level complete, finish (both ambiguous — they also describe a Replay)
|
||||||
|
|
||||||
|
**Replay**:
|
||||||
|
Completing a Level this Playthrough has already cleared. Records Best Time, achievements and statistics to the
|
||||||
|
Profile; commits nothing and never advances campaign position.
|
||||||
|
_Avoid_: Practice, retry, second run
|
||||||
|
|
||||||
|
**Bank**:
|
||||||
|
The coins a Playthrough holds, as opposed to coins found in the current Attempt. Coins are banked by committing,
|
||||||
|
never on pickup.
|
||||||
|
_Avoid_: Total coins, saved coins
|
||||||
|
|
||||||
|
**Discard**:
|
||||||
|
Throwing away an Attempt's ledger — on death, or at the end of a Replay. Purchases made during the Attempt are
|
||||||
|
undone by discarding, not refunded; under the ledger they never happened.
|
||||||
|
_Avoid_: Reset, rollback, revert
|
||||||
|
|
||||||
|
### Things
|
||||||
|
|
||||||
|
**Level**:
|
||||||
|
One playable stage, identified by a stable `LevelId` — never by position in a list. Knows its scene and display
|
||||||
|
name. Ids must survive inserting and reordering levels, because Playthroughs and Best Times both reference them.
|
||||||
|
_Avoid_: Stage, scene, level index
|
||||||
|
|
||||||
|
**Campaign**:
|
||||||
|
The authored ordering of Levels that defines progression — which Level is first, and what follows a given one. One
|
||||||
|
Campaign is shared by every Playthrough: it is authored data, not per-Playthrough state, and it carries no scene
|
||||||
|
or presentation detail.
|
||||||
|
_Avoid_: Level list, level order, world, chapter
|
||||||
|
|
||||||
|
**Cleared Levels**:
|
||||||
|
The Levels a Playthrough has completed at least once. Gates both campaign progression and level select; whether a
|
||||||
|
Level is reachable is computed from this, never stored alongside it.
|
||||||
|
_Avoid_: Unlocked levels, completed levels, available levels
|
||||||
|
|
||||||
|
**Skill**:
|
||||||
|
An ability the player can own and upgrade. `SkillData` is the authored asset describing one (name, icon, upgrade
|
||||||
|
table) and is immutable at runtime; the level the player has reached in it belongs to the Playthrough.
|
||||||
|
_Avoid_: Ability, power-up, upgrade (for the skill itself)
|
||||||
|
|
||||||
|
**Movement Ability**:
|
||||||
|
A composable behaviour attached to the player controller (jump, wall-jump, gravity). Unrelated to Skill —
|
||||||
|
authored per movement preset, not owned, not upgraded, not persisted.
|
||||||
|
_Avoid_: Skill, movement skill
|
||||||
|
|
||||||
|
**Best Time**:
|
||||||
|
The fastest recorded completion of a Level, from a First Clear or a Replay alike. Belongs to the Profile, so it
|
||||||
|
survives New Game and is shared by every Playthrough. Stored alongside, but distinct from, the ghost replay
|
||||||
|
frames that produced it.
|
||||||
|
_Avoid_: Personal best, high score, record
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Reads may cross scopes freely — affordability spans Attempt and Playthrough. Writes must not.
|
||||||
|
- Derived facts are computed, never stored. Level reachability is derived from Cleared Levels; total coins from
|
||||||
|
Attempt plus Bank. Storing either alongside its source creates the second source of truth this context exists
|
||||||
|
to prevent.
|
||||||
|
- Achievements are mirrored to Steam, which is per-account and cannot be un-earned. That is why they are Profile
|
||||||
|
and not Playthrough: a per-Playthrough achievement list would contradict Steam permanently.
|
||||||
|
- Nothing in this context may reference Godot. Its correctness must be checkable without a scene tree.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Godot.NET.Sdk/4.6.2">
|
<Project Sdk="Godot.NET.Sdk/4.7.1">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||||
@@ -9,4 +9,17 @@
|
|||||||
<PackageReference Include="Facepunch.Steamworks.Dlls" Version="2.3.2" />
|
<PackageReference Include="Facepunch.Steamworks.Dlls" Version="2.3.2" />
|
||||||
<PackageReference Include="Facepunch.Steamworks.Library" Version="2.3.3" />
|
<PackageReference Include="Facepunch.Steamworks.Library" Version="2.3.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Godot.NET.Sdk globs **/*.cs, which would compile the progression projects into this
|
||||||
|
assembly as well as their own. They are separate projects; exclude them here. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="progression\**" />
|
||||||
|
<Compile Remove="progression.tests\**" />
|
||||||
|
<Compile Remove="progression.adapters\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="progression\Mr.BrickAdventures.Progression.csproj" />
|
||||||
|
<ProjectReference Include="progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,19 +1,139 @@
|
|||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio 2012
|
# Visual Studio 2012
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr. Brick Adventures", "Mr. Brick Adventures.csproj", "{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr. Brick Adventures", "Mr. Brick Adventures.csproj", "{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression", "progression", "{73CE8E92-AE0D-34EB-F3CD-A6392CC7A1AA}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression", "progression\Mr.BrickAdventures.Progression.csproj", "{4F115C37-24F6-4933-9086-ED5925EE5106}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression.tests", "progression.tests", "{B756B2BA-9E41-468E-DBBE-5BA8CC0A1E41}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Tests", "progression.tests\Mr.BrickAdventures.Progression.Tests.csproj", "{A4660883-E42A-4D04-99A3-770F36E9D861}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression.adapters", "progression.adapters", "{9D5C8041-6C54-0A01-82AE-FC7E0DE079DA}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Adapters", "progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj", "{045D9BF4-8E63-4F14-84F4-F8C55073244C}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
ExportDebug|Any CPU = ExportDebug|Any CPU
|
Debug|x64 = Debug|x64
|
||||||
ExportRelease|Any CPU = ExportRelease|Any CPU
|
Debug|x86 = Debug|x86
|
||||||
|
ExportDebug|Any CPU = ExportDebug|Any CPU
|
||||||
|
ExportDebug|x64 = ExportDebug|x64
|
||||||
|
ExportDebug|x86 = ExportDebug|x86
|
||||||
|
ExportRelease|Any CPU = ExportRelease|Any CPU
|
||||||
|
ExportRelease|x64 = ExportRelease|x64
|
||||||
|
ExportRelease|x86 = ExportRelease|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU
|
||||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x64.ActiveCfg = ExportDebug|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x64.Build.0 = ExportDebug|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x86.ActiveCfg = ExportDebug|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x86.Build.0 = ExportDebug|Any CPU
|
||||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU
|
||||||
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x64.ActiveCfg = ExportRelease|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x64.Build.0 = ExportRelease|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x86.ActiveCfg = ExportRelease|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x86.Build.0 = ExportRelease|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x64.Build.0 = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x86.Build.0 = Debug|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x64.Build.0 = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x86.Build.0 = Debug|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{4F115C37-24F6-4933-9086-ED5925EE5106} = {73CE8E92-AE0D-34EB-F3CD-A6392CC7A1AA}
|
||||||
|
{A4660883-E42A-4D04-99A3-770F36E9D861} = {B756B2BA-9E41-468E-DBBE-5BA8CC0A1E41}
|
||||||
|
{045D9BF4-8E63-4F14-84F4-F8C55073244C} = {9D5C8041-6C54-0A01-82AE-FC7E0DE079DA}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/banner.png-7e9e6a304eef850602c8d5afb80df9c3.c
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.cte
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -26,6 +28,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -26,6 +28,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.c
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
|||||||
msdf_size=48
|
msdf_size=48
|
||||||
allow_system_fallback=true
|
allow_system_fallback=true
|
||||||
force_autohinter=false
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
hinting=1
|
hinting=1
|
||||||
subpixel_positioning=1
|
subpixel_positioning=1
|
||||||
keep_rounding_remainders=true
|
keep_rounding_remainders=true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
|||||||
msdf_size=48
|
msdf_size=48
|
||||||
allow_system_fallback=true
|
allow_system_fallback=true
|
||||||
force_autohinter=false
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
hinting=1
|
hinting=1
|
||||||
subpixel_positioning=1
|
subpixel_positioning=1
|
||||||
keep_rounding_remainders=true
|
keep_rounding_remainders=true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
|||||||
msdf_size=48
|
msdf_size=48
|
||||||
allow_system_fallback=true
|
allow_system_fallback=true
|
||||||
force_autohinter=false
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
hinting=1
|
hinting=1
|
||||||
subpixel_positioning=1
|
subpixel_positioning=1
|
||||||
keep_rounding_remainders=true
|
keep_rounding_remainders=true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
|||||||
msdf_size=48
|
msdf_size=48
|
||||||
allow_system_fallback=true
|
allow_system_fallback=true
|
||||||
force_autohinter=false
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
hinting=1
|
hinting=1
|
||||||
subpixel_positioning=1
|
subpixel_positioning=1
|
||||||
keep_rounding_remainders=true
|
keep_rounding_remainders=true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
|||||||
msdf_size=48
|
msdf_size=48
|
||||||
allow_system_fallback=true
|
allow_system_fallback=true
|
||||||
force_autohinter=false
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
hinting=1
|
hinting=1
|
||||||
subpixel_positioning=1
|
subpixel_positioning=1
|
||||||
keep_rounding_remainders=true
|
keep_rounding_remainders=true
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/PhantomCameraBtnPrimaryDefault.png-fcf3696b58
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/PhantomCameraBtnPrimaryHover.png-3d2e4d225f6a
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/inventory_container.png-12241277f279bfc4bf7d5
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/level_spritesheet.png-26a44dd21a040a5480d5ccb
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_2d_sprite.png-deab230b83ae03ae
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/player_sprite.svg-8862ecb19e12152eb892607676f
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/sign_prompt.png-18d451127e1cd1a16367acd23cec4
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/checker_pattern_dark.png-70cedad2d3abf4ad6166
|
|||||||
compress/mode=2
|
compress/mode=2
|
||||||
compress/high_quality=true
|
compress/high_quality=true
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -26,6 +28,10 @@ mipmaps/generate=true
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/target.png-878c5e8d057c8a9a4c2322d4ab88e9ef.s
|
|||||||
compress/mode=2
|
compress/mode=2
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -26,6 +28,10 @@ mipmaps/generate=true
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
|||||||
msdf_size=48
|
msdf_size=48
|
||||||
allow_system_fallback=true
|
allow_system_fallback=true
|
||||||
force_autohinter=false
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
hinting=1
|
hinting=1
|
||||||
subpixel_positioning=1
|
subpixel_positioning=1
|
||||||
keep_rounding_remainders=true
|
keep_rounding_remainders=true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
|||||||
msdf_size=48
|
msdf_size=48
|
||||||
allow_system_fallback=true
|
allow_system_fallback=true
|
||||||
force_autohinter=false
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
hinting=1
|
hinting=1
|
||||||
subpixel_positioning=1
|
subpixel_positioning=1
|
||||||
keep_rounding_remainders=true
|
keep_rounding_remainders=true
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/PriorityOverride.svg-e76e07f4bbd98169f119e17f
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_2d.svg-e5483cbc858fc5f95f7210b
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_3d.svg-41ed612e834470377fb56ee
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_camera_3d_resource.svg-f8bf8d1
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_gizmo.svg-ba1aacb9b1c5f4ef401d
|
|||||||
compress/mode=2
|
compress/mode=2
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -26,6 +28,10 @@ mipmaps/generate=true
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_glow_logo.png-078f944973b55b32
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_host.svg-3150f8f2d82ca9ecab9a3
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -26,6 +28,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_logo.png-8b8d347b5e4800c86cd80
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_2d.svg-1b3d37fe3
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_3d.svg-9b90fe54a
|
|||||||
compress/mode=2
|
compress/mode=2
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -26,6 +28,10 @@ mipmaps/generate=true
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_emitter_gizmo.svg-9a5938
|
|||||||
compress/mode=2
|
compress/mode=2
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -26,6 +28,10 @@ mipmaps/generate=true
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_noise_resource.svg-a81ed223714
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dest_files=["res://.godot/imported/phantom_camera_tween.svg-16faced08ef4a5f34582
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -26,6 +28,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/phantom_camera_updater_panel_icon.svg-19823e6
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/Camera2DIcon.svg-300e6f57281180711c5ecf391104
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/Camera3DIcon.svg-4805c46004db1c89cc9443dd7406
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/SceneTypesIcon.svg-66e2255bd3398007bec03a5cbf
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/Select.svg-cdf90b8b400d3b91a023b70c6a823894.c
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/warning.svg-c1b21c265e0842bbdc9ed10735995366.
|
|||||||
compress/mode=0
|
compress/mode=0
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
@@ -25,6 +27,10 @@ mipmaps/generate=false
|
|||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
process/fix_alpha_border=true
|
process/fix_alpha_border=true
|
||||||
process/premult_alpha=false
|
process/premult_alpha=false
|
||||||
process/normal_map_invert_y=false
|
process/normal_map_invert_y=false
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[gd_resource type="Theme" load_steps=12 format=3 uid="uid://bhppejri5dbsf"]
|
[gd_resource type="Theme" format=3 uid="uid://bhppejri5dbsf"]
|
||||||
|
|
||||||
[ext_resource type="FontFile" uid="uid://dve7mgsjik4dg" path="res://addons/phantom_camera/fonts/Nunito-Regular.ttf" id="1_5rtjh"]
|
[ext_resource type="FontFile" uid="uid://dve7mgsjik4dg" path="res://addons/phantom_camera/fonts/Nunito-Regular.ttf" id="1_5rtjh"]
|
||||||
[ext_resource type="StyleBox" uid="uid://5weqvkjsfso3" path="res://addons/phantom_camera/themes/button_hover.tres" id="2_du6h5"]
|
[ext_resource type="StyleBox" uid="uid://5weqvkjsfso3" path="res://addons/phantom_camera/themes/button_hover.tres" id="2_du6h5"]
|
||||||
|
|||||||
15
docs/adr/0001-three-progression-scopes.md
Normal file
15
docs/adr/0001-three-progression-scopes.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Player progression has three scopes, not two
|
||||||
|
|
||||||
|
Progression state was two scopes — `PlayerState` (persisted) and `SessionState` (transient) — with achievements
|
||||||
|
and best times leaking out as special cases: `PlayerState.Reset()` deliberately skipped `UnlockedAchievements`,
|
||||||
|
and best times lived in the ghost replay files rather than the save. We split progression into **Attempt**,
|
||||||
|
**Playthrough** and **Profile** (defined in `CONTEXT.md`), because introducing save slots makes the third scope
|
||||||
|
mandatory rather than a preference: Steam achievements are per-account and cannot be un-earned, so a
|
||||||
|
per-Playthrough achievement list would permanently contradict Steam the moment a second slot exists. Best times
|
||||||
|
follow the same argument — a completionist's record is not something starting a new game should reset.
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
Keeping one persisted root with a hand-maintained list of fields that survive New Game. Rejected: that is exactly
|
||||||
|
what the old `Reset()` did, and it had already drifted — achievements survived by omission rather than by design,
|
||||||
|
and nothing would have caught the next missed field.
|
||||||
16
docs/adr/0002-attempt-is-a-ledger.md
Normal file
16
docs/adr/0002-attempt-is-a-ledger.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Attempt is a separate aggregate holding a ledger
|
||||||
|
|
||||||
|
**Playthrough** and **Attempt** are separate aggregate roots rather than one root containing the other, and an
|
||||||
|
Attempt records a *ledger* — coins found, coins spent, skills bought — rather than just a purse of coins found.
|
||||||
|
Separate roots keeps each aggregate small and gives the Attempt its own lifecycle, since it is never persisted.
|
||||||
|
The ledger exists so that the rule "nothing survives an Attempt unless you complete the level" applies to
|
||||||
|
purchases too: under the previous model `RemoveCoins` drained session coins and then debited banked coins
|
||||||
|
immediately, so buying a skill mid-level and then dying lost both the coins and the skill.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Affordability is a cross-aggregate **read** (`attempt.CoinsFound + playthrough.Bank >= cost`). Reads may cross
|
||||||
|
scopes; writes may not.
|
||||||
|
- Purchasing writes only the Attempt. The only operation touching both roots is Commit, which has exactly one
|
||||||
|
call site.
|
||||||
|
- Discarding an Attempt undoes purchases rather than refunding them — under the ledger they never happened.
|
||||||
15
docs/adr/0003-only-first-clear-commits.md
Normal file
15
docs/adr/0003-only-first-clear-commits.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Only a First Clear commits an Attempt
|
||||||
|
|
||||||
|
The game is moving from a purely sequential campaign to one where completionists can select and replay levels
|
||||||
|
they have already beaten. Coins and skills bank only on a **First Clear** — the first completion of a Level
|
||||||
|
within a Playthrough — while a **Replay** records Best Time, achievements and statistics to the Profile and
|
||||||
|
discards the Attempt. We chose this over committing on every completion because grinding the easiest level would
|
||||||
|
otherwise be the optimal way to buy every skill.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A Replay must never advance campaign position. Sharing one code path between First Clear and Replay without an
|
||||||
|
explicit branch is the obvious way to reintroduce this bug.
|
||||||
|
- `ClearedLevels` answers "is this a First Clear?" for free, since it already deduplicates.
|
||||||
|
- Coins found during a Replay are lost on completion, not just on death. This is intentional and worth surfacing
|
||||||
|
in the UI.
|
||||||
21
docs/adr/0004-domain-does-not-reference-godot.md
Normal file
21
docs/adr/0004-domain-does-not-reference-godot.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# The progression domain does not reference Godot
|
||||||
|
|
||||||
|
The progression domain lives in its own assembly with no Godot reference. Aggregates raise plain C# event records
|
||||||
|
as part of their operations, and a single adapter republishes those as `EventBus` signals. This is the only
|
||||||
|
enforceable version of the goal — correctness checkable without a scene tree, with the compiler enforcing the rule
|
||||||
|
rather than discipline. The leak it removes was already visible: `PlayerState` documented itself as a POCO "for
|
||||||
|
predictable state management" while holding `List<SkillData>`, and `SkillData : Resource`, which is why saves had
|
||||||
|
to store skill *names* and re-resolve them through `SkillManager` on load.
|
||||||
|
|
||||||
|
Republishing rather than replacing `EventBus` keeps existing scene wiring and subscribers intact, and makes
|
||||||
|
"state changed ⇒ signal fired" un-bypassable — the original defect, where `ConsoleManager` mutated state directly
|
||||||
|
and re-emitted signals by hand, sometimes without the invariants the store's own operations applied.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `GameStateStore` stops being a `Node`. The autoload becomes a composition root that owns the aggregates, so the
|
||||||
|
aggregates' lifetime is no longer tied to the scene tree and most `Store?.` null-guards disappear.
|
||||||
|
- Skill progression cannot live on `SkillData`. A `Resource` is one cached instance per process, so with save
|
||||||
|
slots it would leak levels between Playthroughs.
|
||||||
|
- Godot types (`Vector2`, `Mathf`, `GD.Print`) are not available in the domain; where they appear today they are
|
||||||
|
replaced by domain values or moved to adapters.
|
||||||
28
docs/adr/0005-playthrough-settles-attempts.md
Normal file
28
docs/adr/0005-playthrough-settles-attempts.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Playthrough has no LoseLife; it settles Attempts
|
||||||
|
|
||||||
|
Playthrough and Attempt are separate aggregate roots (ADR-0002), but "one death costs exactly one life" requires
|
||||||
|
ending the Attempt and decrementing lives *together*. Rather than expose bare mutators and rely on the caller to
|
||||||
|
sequence them, Playthrough exposes only what happened — `Commit(attempt)`, `SettleDeath(attempt)`,
|
||||||
|
`SettleReplay(attempt)` — each of which calls the Attempt's own `TryEnd…` internally and does nothing when it
|
||||||
|
returns false. Every operation is therefore atomic and idempotent, and there is no `LoseLife` to call from
|
||||||
|
somewhere else.
|
||||||
|
|
||||||
|
The alternative shape, `if (attempt.TryEndInDeath()) playthrough.LoseLife();`, puts the idempotency in the domain
|
||||||
|
but leaves the sequencing to the caller. A public `LoseLife` is an invitation: the code being replaced has two
|
||||||
|
paths to exactly that operation — `LivesStateHandler.RemoveLife` and `ConsoleManager` writing `Player.Lives`
|
||||||
|
directly, unclamped — because the operation existed to be called.
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
Collapsing the two into one aggregate with Attempt as an inner entity, making the atomicity structural. Rejected:
|
||||||
|
`Commit(Attempt)` already gave the shape we needed, and passing one root into another's method is a transient
|
||||||
|
reference rather than a stored association, so ADR-0002 stands unchanged.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Overlapping hazards in a kill zone, or a hazard re-firing during the two seconds before `DeathScreen` reloads,
|
||||||
|
cost one life rather than two.
|
||||||
|
- `ExitDoorComponent` and `ConsoleManager.GoToNextLevelCommand` can both request completion; only the first banks.
|
||||||
|
- The application layer holds the active Attempt and passes it in. The Attempt carries its own `LevelId`, so
|
||||||
|
`Commit` decides First Clear versus Replay from the Attempt itself rather than trusting a level supplied
|
||||||
|
alongside it — finishing a Replay stops being a way to corrupt campaign position.
|
||||||
322
docs/plans/phase-a-progression-domain.md
Normal file
322
docs/plans/phase-a-progression-domain.md
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# Phase A — Progression domain
|
||||||
|
|
||||||
|
Replace the current state layer with the model in [`CONTEXT.md`](../../CONTEXT.md), governed by
|
||||||
|
[ADR-0001..0004](../adr/). **No new player-facing features.** Save files are wiped once; the game is unreleased so
|
||||||
|
this costs nothing (ADR context).
|
||||||
|
|
||||||
|
Phase B — slot picker UI, level select, Replay surfacing, best times out of the ghost files — is out of scope
|
||||||
|
here. Phase A does put the *persistence format* in place for slots, so Phase B is UI work rather than another
|
||||||
|
model change.
|
||||||
|
|
||||||
|
## Two kinds of slice
|
||||||
|
|
||||||
|
| | verified by | `/tdd` |
|
||||||
|
|---|---|---|
|
||||||
|
| **Slices 1–6** — pure domain, no Godot | `dotnet test` | yes — red/green/refactor throughout |
|
||||||
|
| **Slices 7–13** — adapters and wiring | compiler + playing the game | no — write the test where one is possible, otherwise verify by hand |
|
||||||
|
|
||||||
|
Don't try to TDD slices 7–13. You can't red-green an autoload swap or a `.tscn` connection deletion, and forcing
|
||||||
|
it produces tests that assert the mock rather than the behaviour. The value of slices 1–6 is that they absorb
|
||||||
|
*all* the logic worth testing, so the untestable slices become mechanical.
|
||||||
|
|
||||||
|
Each slice ends with the build green and the game runnable. Don't start the next one until it is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 0 — scaffolding
|
||||||
|
|
||||||
|
No tests. Pure setup.
|
||||||
|
|
||||||
|
- `progression/Mr.BrickAdventures.Progression.csproj` — plain `net8.0` library, **no Godot reference**, root
|
||||||
|
namespace `Mr.BrickAdventures.Progression`.
|
||||||
|
- `progression.tests/Mr.BrickAdventures.Progression.Tests.csproj` — xunit (NUnit is equally fine), references the
|
||||||
|
domain library **only**.
|
||||||
|
- Add both to `Mr. Brick Adventures.sln`. Add a `ProjectReference` from the game project to the domain library.
|
||||||
|
- Set `<EnableDynamicLoading>true</EnableDynamicLoading>` in the domain library too, not just the game project —
|
||||||
|
Godot needs it on referenced assemblies for hot reload, and omitting it produces confusing load failures.
|
||||||
|
|
||||||
|
**Done when:** `dotnet test` runs and reports zero tests; Godot still builds; the game still launches and plays.
|
||||||
|
|
||||||
|
**Guard rail for the whole phase:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# must print nothing — anchored to real using-directives
|
||||||
|
grep -rnE "^\s*(global\s+)?using\s+Godot" progression progression.adapters progression.tests --include=*.cs
|
||||||
|
# stronger, and cannot be fooled by wording: no project may resolve the engine assembly at all
|
||||||
|
grep -c GodotSharp progression/obj/project.assets.json progression.adapters/obj/project.assets.json
|
||||||
|
```
|
||||||
|
|
||||||
|
That check is what ADR-0004 buys you. Match using-directives **anchored to the start of a line**: a bare
|
||||||
|
`grep -r "using Godot"` also hits `.csproj` comments and doc comments that legitimately mention the engine in prose,
|
||||||
|
and reports a false violation. The `project.assets.json` check is the one that cannot be fooled by wording.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 1 — value objects · TDD
|
||||||
|
|
||||||
|
`Coins`, `Lives`, `LevelId`, `SkillId`, `SkillLevel`, `StatName`, `LevelTime`, `PlaythroughId`.
|
||||||
|
|
||||||
|
Invariants to drive out with tests:
|
||||||
|
|
||||||
|
- `Coins` cannot be negative. Subtraction that would go negative is a failure the caller must handle, not a
|
||||||
|
silent clamp — the current `Mathf.Max(0, ...)` clamp in one path and absence of it in another is exactly the
|
||||||
|
bug this removes.
|
||||||
|
- `Lives` floors at zero, and reaching zero is observable.
|
||||||
|
- `LevelId` is a non-empty stable string. Not an int, not an index.
|
||||||
|
- `SkillLevel` starts at 1 and has no zero value — "unowned" is represented by *absence*, never by level 0. See
|
||||||
|
the `SkillData.Level = 1` ambiguity this replaces.
|
||||||
|
- `LevelTime` is positive; comparison is what "best" means.
|
||||||
|
- `StatName` is a closed set (the five in `StatNames` today), so a typo cannot invent a statistic.
|
||||||
|
|
||||||
|
**Done when:** no value object can hold invalid state, and every clamp/guard currently scattered across
|
||||||
|
`GameStateStore` and `ConsoleManager` exists in exactly one place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 2 — Attempt · TDD
|
||||||
|
|
||||||
|
The ledger from ADR-0002: `CoinsFound`, `CoinsSpent`, `SkillsBought`, plus an outcome.
|
||||||
|
|
||||||
|
- `Attempt(LevelId level)` and `LevelId Level` — the Attempt knows which Level it is an attempt at, so
|
||||||
|
`Commit` decides First Clear versus Replay from the Attempt rather than from a level the caller passes alongside
|
||||||
|
- `Outcome` — `InProgress | Died | Cleared`
|
||||||
|
- `void RecordCoinsFound(Coins)` — silently ignores late coins once ended
|
||||||
|
- `bool TryRecordPurchase(SkillId, SkillLevel resulting, Coins cost)` — records where the skill ended up, since
|
||||||
|
one Attempt can both unlock and upgrade the same skill
|
||||||
|
- `bool TryEndInDeath()` / `bool TryEndInCompletion()` — return false if the Attempt already ended
|
||||||
|
- No `Discard` method: discarding is throwing the object away.
|
||||||
|
|
||||||
|
The recording methods are deliberately asymmetric. A coin pickup firing after death is a real event — the player
|
||||||
|
node survives for two seconds before `DeathScreen` reloads — and its caller has nothing useful to do with a
|
||||||
|
`false`, so it returns nothing. A purchase's caller does care.
|
||||||
|
|
||||||
|
`CoinsFound` and `CoinsSpent` are both non-negative. There is no signed net value — settlement is
|
||||||
|
`bank.Add(found).TrySubtract(spent)` in `Playthrough.Commit`, which in that order cannot go negative given
|
||||||
|
affordability was checked at purchase time.
|
||||||
|
|
||||||
|
> Corrects an earlier draft of this plan, which had `NetCoins` "may be negative". That would have forced `Coins`
|
||||||
|
> to permit negatives and defeated the point of the type.
|
||||||
|
|
||||||
|
**The outcome is the idempotency guard, and it is the reason this slice matters.** An Attempt is the thing that
|
||||||
|
dies or is cleared, and it can only do either once. `Lives` flooring at zero does not solve this: losing two lives
|
||||||
|
from a single death takes you from 3 to 1, which no clamp catches.
|
||||||
|
|
||||||
|
Tests worth writing:
|
||||||
|
|
||||||
|
- **An Attempt cannot end twice.** Second `TryEndInDeath` returns false and changes nothing.
|
||||||
|
- **An Attempt that died cannot then be cleared**, and vice versa.
|
||||||
|
- Spending more than found is representable — the shortfall is settled at Commit, not rejected here.
|
||||||
|
- Recording anything after the Attempt ended is refused.
|
||||||
|
|
||||||
|
Two real call sites motivate this. Death: overlapping hazards in a kill zone, or a hazard re-triggering during the
|
||||||
|
two seconds before `DeathScreen` reloads the scene — the player node still exists with zero health. Completion:
|
||||||
|
`ExitDoorComponent:46` and `ConsoleManager.GoToNextLevelCommand:99` both call `OnLevelComplete()`.
|
||||||
|
|
||||||
|
`HealthComponent.ApplyHealthChange` currently absorbs repeat deaths via its `delta == 0` early return, but that is
|
||||||
|
incidental and `Health` is a public `[Export]` setter that bypasses it. Do not rely on the engine side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 3 — Playthrough · TDD
|
||||||
|
|
||||||
|
Aggregate root. `PlaythroughId`, `Lives`, `Bank`, `OwnedSkills : SkillId → SkillLevel`, `CurrentLevel : LevelId`,
|
||||||
|
`ClearedLevels`.
|
||||||
|
|
||||||
|
Operations: `SettleDeath(Attempt)`, `SettleCompletion(Attempt, Campaign)`, `CanAfford(Attempt, Coins)`,
|
||||||
|
`HasCleared(LevelId)`, `ReachableLevels(Campaign)`, `IsRunOver`.
|
||||||
|
|
||||||
|
**There is no `LoseLife`** — see ADR-0005. Each settle operation calls the Attempt's `TryEnd…` internally and does
|
||||||
|
nothing when it returns false, so it is atomic and idempotent, and no bare mutator exists to be called from a
|
||||||
|
second place.
|
||||||
|
|
||||||
|
**Two settle operations, not three.** An earlier draft had `Commit` and `SettleReplay` as separate calls, which
|
||||||
|
made the *caller* decide whether a completion was a First Clear or a Replay — precisely the ADR-0003 regression.
|
||||||
|
`SettleCompletion` branches on `HasCleared(attempt.Level)` itself, so it cannot be selected wrongly.
|
||||||
|
|
||||||
|
**`Campaign`** is a domain value object holding the authored ordering of `LevelId`s (`First`, `Next`, `Contains`).
|
||||||
|
`ILevelCatalogue` stays on the adapter side resolving a `LevelId` to a scene handle and display name; the ordering
|
||||||
|
is pure data handed in as a `Campaign`, so the aggregate never touches a port.
|
||||||
|
|
||||||
|
**`Commit` does not validate skill levels against the upgrade cap.** Max level comes from
|
||||||
|
`SkillData.Upgrades.Count`, which is authored data the domain cannot see. The cap is a purchase-time rule enforced
|
||||||
|
in slice 6's `UpgradeSkill`, which is also where `TryUpgradeSkill`'s current `Upgrades[skill.Level]` overrun gets a
|
||||||
|
test. `Commit` applies what the ledger recorded.
|
||||||
|
|
||||||
|
Events leave via `IReadOnlyList<ProgressionEvent> DrainEvents()`. The composition root drains once after every use
|
||||||
|
case and republishes (ADR-0004), so operations stay `void` and the "forgot to drain" risk collapses to one line in
|
||||||
|
one place.
|
||||||
|
|
||||||
|
Raises: `LifeLost(remaining)`, `RunEnded`, `BankChanged(total)`, `SkillSetChanged`.
|
||||||
|
|
||||||
|
This is where the report's bugs become tests. Write these first, watch them fail:
|
||||||
|
|
||||||
|
- **Losing the last life sets lives to zero *and* raises `RunEnded` in one operation.** This is the last-life
|
||||||
|
soft-lock — today two screens read `Lives` before the handler writes it. Make it one atomic operation and the
|
||||||
|
race cannot be expressed.
|
||||||
|
- **Two deaths in one Attempt cost one life.** Pairs with slice 2's outcome guard; assert it here at the
|
||||||
|
Playthrough level too, because this is the invariant a player would actually notice.
|
||||||
|
- **`Commit` on an already-cleared level does not advance and does not bank** (ADR-0003).
|
||||||
|
- **`Commit` settles as `bank.Add(found).TrySubtract(spent)`**, in that order, so an Attempt that overspent
|
||||||
|
reduces the Bank correctly and can never drive it negative. A discarded Attempt leaves the Bank untouched —
|
||||||
|
the "buy mid-level, die, lose coins *and* skill" bug.
|
||||||
|
- **`ReachableLevels` is computed** from `ClearedLevels` plus level order, never stored. `UnlockedLevels` and
|
||||||
|
`IsLevelUnlocked` are deleted, not ported.
|
||||||
|
- **`Bank` cannot go negative** even through Commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 4 — Profile · TDD
|
||||||
|
|
||||||
|
Aggregate root, one per installation. `Achievements`, `BestTimes : LevelId → LevelTime`, `Statistics`.
|
||||||
|
|
||||||
|
- `RecordTime(LevelId, LevelTime)` — only replaces a slower time. Recording a worse time is a no-op, not an error.
|
||||||
|
- `UnlockAchievement(id)` — idempotent, raises `AchievementUnlocked` only on the transition.
|
||||||
|
- `Record(StatName, int)`.
|
||||||
|
- `RelockAchievement(id)` exists for the console only, and is documented as **not** reaching Steam — that
|
||||||
|
asymmetry is unavoidable (Steam achievements cannot be un-earned) so make it explicit rather than surprising.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 5 — ports and in-memory adapters · TDD
|
||||||
|
|
||||||
|
Interfaces in the domain: `IPlaythroughStore`, `IProfileStore`, `ILevelCatalogue`, `IGhostStore`.
|
||||||
|
|
||||||
|
In-memory implementations live in the **test project**. That's what makes the seam real rather than hypothetical —
|
||||||
|
two adapters, one of which ships and one of which doesn't.
|
||||||
|
|
||||||
|
- Round-trip: save a Playthrough, load it, assert equality. Same for Profile.
|
||||||
|
- Loading an absent Playthrough and loading a *corrupt* one are different outcomes. Today both surface as
|
||||||
|
`false` from `LoadGame`, which is why a version bump silently overwrites saves.
|
||||||
|
- `ILevelCatalogue` returns ordered `LevelId`s and resolves one to a scene reference — as an opaque handle, since
|
||||||
|
the domain can't name `PackedScene`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 6 — use cases · TDD
|
||||||
|
|
||||||
|
`StartPlaythrough`, `LoadPlaythrough`, `EnterLevel`, `FinishLevel`, `HandlePlayerDeath`, `PurchaseSkill`,
|
||||||
|
`UpgradeSkill`.
|
||||||
|
|
||||||
|
Four of these carry the orchestration that is currently spread across `GameManager`, the `Events/` handlers and
|
||||||
|
the UI. Each of the remaining report bugs gets a test here:
|
||||||
|
|
||||||
|
- **`EnterLevel` always announces `LevelStarted`.** Today only `TryToGoToNextLevel` emits it, so ghost recording
|
||||||
|
never starts on the first level or after Continue, and a continued run records completion times of zero.
|
||||||
|
- **`FinishLevel` branches First Clear vs Replay** and a Replay never advances `CurrentLevel` (ADR-0003 flags this
|
||||||
|
as the obvious regression).
|
||||||
|
- **`HandlePlayerDeath` loses a life and discards the Attempt as one operation.**
|
||||||
|
- **`PurchaseSkill` writes only the Attempt**, checks affordability across Attempt + Bank, and rejects rather than
|
||||||
|
throwing when short.
|
||||||
|
- **`UpgradeSkill` reads the level from the Playthrough**, and cannot index past the upgrade table.
|
||||||
|
|
||||||
|
**Done when:** every bug listed in the architecture review has a test that failed before the fix and passes after.
|
||||||
|
That is the deliverable of Phase A — the point where "hidden bugs" stops being a category.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Everything above is pure C# with no Godot. Everything below touches the engine. Switch verification mode here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 7 — level catalogue adapter
|
||||||
|
|
||||||
|
`resources/levels/` already holds six `LevelResource` files (`village_1..5`, `forest_1`) but there are eight level
|
||||||
|
scenes and only two scenes reference a resource at all. Complete and adopt them.
|
||||||
|
|
||||||
|
- Add `LevelId` and explicit order to `LevelResource`; create the two missing entries (`tutorial_1`,
|
||||||
|
`village_6`).
|
||||||
|
- A catalogue resource lists them in order. Adapter implements `ILevelCatalogue`.
|
||||||
|
- Delete `GameManager.LevelScenes` and all array-index addressing.
|
||||||
|
- `LevelResource.ScenePath` is currently never read — either it becomes how the catalogue resolves scenes, or it
|
||||||
|
goes. Don't leave it.
|
||||||
|
|
||||||
|
**Verify:** game boots into the first level; order matches the catalogue; `DeathScreen`'s level-name label still
|
||||||
|
populates.
|
||||||
|
|
||||||
|
## Slice 8 — JSON store adapters
|
||||||
|
|
||||||
|
- `user://profile.json` and `user://playthrough_{id}.json`. The slot format lands now even though the UI only
|
||||||
|
ever creates one; Phase B then adds a picker and nothing about the format changes.
|
||||||
|
- Write to a temp file and rename, so a crash mid-save can't corrupt a slot. With N slots this stops being
|
||||||
|
theoretical.
|
||||||
|
- Keep a version field, but replace the equality check with a migration hook that can return
|
||||||
|
"unmigratable" — distinct from "absent".
|
||||||
|
|
||||||
|
**Verify:** play, quit, continue. Then hand-corrupt a file and confirm you get an error rather than a silent new
|
||||||
|
game.
|
||||||
|
|
||||||
|
## Slice 9 — composition root and republisher
|
||||||
|
|
||||||
|
- New autoload owns the `Profile` and the active `Playthrough`. It is the only `Node` in this area.
|
||||||
|
- Republisher subscribes to domain events and emits the existing `EventBus` signals (ADR-0004), so `Hud`,
|
||||||
|
`DeathScreen` and the rest keep working unchanged.
|
||||||
|
- Delete `GameStateStore`. Register the new autoload in `project.godot` in its place.
|
||||||
|
|
||||||
|
**Verify:** HUD shows coins and lives; a coin pickup updates them.
|
||||||
|
|
||||||
|
## Slice 10 — swap the call sites
|
||||||
|
|
||||||
|
Mechanical and compiler-driven — sealing the aggregates turns every old write into a compile error, which is the
|
||||||
|
cheapest way to find them all.
|
||||||
|
|
||||||
|
- `Events/` handlers become thin adapters that call use cases. `CoinStateHandler` already has this shape.
|
||||||
|
- `ConsoleManager` calls use cases instead of writing fields and hand-re-emitting signals.
|
||||||
|
- `AchievementManager` reads and writes the `Profile` instead of reaching into `PlayerState`.
|
||||||
|
- `SkillManager` reconciles `ActiveComponents` from `SkillSetChanged` — `ApplyUnlockedSkills` becomes the only
|
||||||
|
path, so the four two-call pairs collapse to one call each and a skill can no longer end up owned but inert.
|
||||||
|
- Delete `SaveSystem`, `PlayerState`, `SessionState`, `SaveDataDto`.
|
||||||
|
|
||||||
|
**Verify:** compiler first, then play one level start to finish including a purchase.
|
||||||
|
|
||||||
|
## Slice 11 — skill level off the asset
|
||||||
|
|
||||||
|
- `ISkill.Initialize(Node owner, SkillData data, int level)`. Update `BrickThrowComponent:90`,
|
||||||
|
`MagneticSkillComponent:97`, `BrickArmorSkillComponent:25` to use the passed level rather than `Data.Level`.
|
||||||
|
`BrickArmorSkillComponent` indexes `Upgrades[Level - 1]` unguarded — fix while you're there.
|
||||||
|
- Delete `SkillData.Level`. `SkillData` becomes immutable authored data (CONTEXT.md).
|
||||||
|
- `MarketplaceButton` and `Marketplace` read the level from the Playthrough.
|
||||||
|
|
||||||
|
**Verify:** buy a skill, upgrade it, quit, continue — level persists. Game over, New Game — level is gone. Both
|
||||||
|
are currently wrong.
|
||||||
|
|
||||||
|
**Landmine to note:** five skills (`brick_armor`, `brick_shield`, `double_jump`, `ground_pound_skill`,
|
||||||
|
`wall_jump`) have no `upgrades/` entries, so `Upgrades.Count == 0`. `TryUnlockSkill` would throw on
|
||||||
|
`Upgrades[0].Cost`; it's currently unreachable only because `MarketplaceButton` hides zero-upgrade skills. Decide
|
||||||
|
whether those skills are purchasable at all and make the domain say so.
|
||||||
|
|
||||||
|
## Slice 12 — one death path
|
||||||
|
|
||||||
|
- Delete the 18 `Death` → `OnPlayerDeath` connections across `objects/level/base_level.tscn` and the eight level
|
||||||
|
scenes.
|
||||||
|
- `DeathScreen` and `GameOverScreen` subscribe to republished `LivesChanged` / `RunEnded`.
|
||||||
|
- `DeathScreen` currently reads `Player.Lives` directly *and* subscribes to `LivesChanged` — it keeps only the
|
||||||
|
subscription.
|
||||||
|
|
||||||
|
**Verify:** die with lives remaining → scene reloads. Die on the last life → game over appears. The second case
|
||||||
|
does not work today.
|
||||||
|
|
||||||
|
## Slice 13 — delete the dead surface
|
||||||
|
|
||||||
|
- The four never-emitted signals (`LevelRestarted`, `EnemyDamaged`, `SkillActivated`, `SkillDeactivated`).
|
||||||
|
- Of the eleven emitted-but-unheard: keep the ones the republisher now feeds, delete the speculative ones.
|
||||||
|
- Rename `EventBus.SkillUnlocked` — it collides with `SkillUnlockerComponent.SkillUnlocked`, and every apparent
|
||||||
|
subscriber is bound to the component's.
|
||||||
|
- `SaveSystem.DeleteSave`, `SpeedRunManager.StopTimer`, `AchievementManager.IsAchievementUnlocked`,
|
||||||
|
`Constants.CoinsGroup` — zero callers each.
|
||||||
|
- `SpeedRunManager`: `StopTimer` carries a "save personal best" comment for logic that never existed. Best times
|
||||||
|
are the Profile's job now; delete the comment and the dead method.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Order dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
0 ──> 1 ──> 2 ──┐
|
||||||
|
3 ──┼──> 5 ──> 6 ──> 9 ──> 10 ──> 11 ──> 12 ──> 13
|
||||||
|
4 ──┘ ↑
|
||||||
|
7 ───┘
|
||||||
|
8 ───┘
|
||||||
|
```
|
||||||
|
|
||||||
|
2, 3 and 4 are independent of each other after 1. 7 and 8 can be done any time after 5, and both must precede 9.
|
||||||
|
11, 12 and 13 each require 10.
|
||||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
|||||||
msdf_size=48
|
msdf_size=48
|
||||||
allow_system_fallback=true
|
allow_system_fallback=true
|
||||||
force_autohinter=false
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
hinting=1
|
hinting=1
|
||||||
subpixel_positioning=1
|
subpixel_positioning=1
|
||||||
keep_rounding_remainders=true
|
keep_rounding_remainders=true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
|||||||
msdf_size=48
|
msdf_size=48
|
||||||
allow_system_fallback=true
|
allow_system_fallback=true
|
||||||
force_autohinter=false
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
hinting=1
|
hinting=1
|
||||||
subpixel_positioning=1
|
subpixel_positioning=1
|
||||||
keep_rounding_remainders=true
|
keep_rounding_remainders=true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
|||||||
msdf_size=48
|
msdf_size=48
|
||||||
allow_system_fallback=true
|
allow_system_fallback=true
|
||||||
force_autohinter=false
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
hinting=1
|
hinting=1
|
||||||
subpixel_positioning=1
|
subpixel_positioning=1
|
||||||
keep_rounding_remainders=true
|
keep_rounding_remainders=true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ msdf_pixel_range=8
|
|||||||
msdf_size=48
|
msdf_size=48
|
||||||
allow_system_fallback=true
|
allow_system_fallback=true
|
||||||
force_autohinter=false
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
hinting=1
|
hinting=1
|
||||||
subpixel_positioning=1
|
subpixel_positioning=1
|
||||||
keep_rounding_remainders=true
|
keep_rounding_remainders=true
|
||||||
|
|||||||
@@ -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="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"]
|
[ext_resource type="Script" uid="uid://chrhjch4ymfvr" path="res://scripts/Screenshot.cs" id="6_bbtu1"]
|
||||||
|
|
||||||
[node name="GameManager" type="Node" unique_id=2076336659]
|
[node name="GameManager" type="Node" unique_id=2076336659]
|
||||||
script = ExtResource("1_t2tr6")
|
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]
|
[node name="Screenshot" type="Node" parent="." unique_id=812485936]
|
||||||
script = ExtResource("6_bbtu1")
|
script = ExtResource("6_bbtu1")
|
||||||
|
|||||||
@@ -110,8 +110,6 @@ tile_set = ExtResource("11_0vx27")
|
|||||||
[node name="Killzone" parent="." unique_id=319936093 instance=ExtResource("13_0vx27")]
|
[node name="Killzone" parent="." unique_id=319936093 instance=ExtResource("13_0vx27")]
|
||||||
position = Vector2(215, 324)
|
position = Vector2(215, 324)
|
||||||
|
|
||||||
[connection signal="Death" from="Brick Player/HealthComponent" to="UI Layer/DeathScreen" method="OnPlayerDeath"]
|
|
||||||
[connection signal="Death" from="Brick Player/HealthComponent" to="UI Layer/GameOverScreen" method="OnPlayerDeath"]
|
|
||||||
|
|
||||||
[editable path="Brick Player"]
|
[editable path="Brick Player"]
|
||||||
[editable path="UI Layer"]
|
[editable path="UI Layer"]
|
||||||
|
|||||||
9
objects/progression_root.tscn
Normal file
9
objects/progression_root.tscn
Normal file
@@ -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
|
||||||
0
progression.adapters/.gdignore
Normal file
0
progression.adapters/.gdignore
Normal file
77
progression.adapters/JsonFiles.cs
Normal file
77
progression.adapters/JsonFiles.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Mr.BrickAdventures.Progression.Adapters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reading and writing versioned save files. Shared by the Playthrough and Profile stores, which
|
||||||
|
/// differ only in what they hold and where they live.
|
||||||
|
/// </summary>
|
||||||
|
internal static class JsonFiles
|
||||||
|
{
|
||||||
|
internal static readonly JsonSerializerOptions Options = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes to a temporary file and then renames it over the destination. A crash mid-write leaves
|
||||||
|
/// the previous file intact rather than a half-written one — which matters more once there are
|
||||||
|
/// several slots to lose.
|
||||||
|
/// </summary>
|
||||||
|
internal static void WriteAtomic<T>(string path, int version, T data)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
|
||||||
|
var temp = path + ".tmp";
|
||||||
|
File.WriteAllText(temp, JsonSerializer.Serialize(new SaveEnvelope<T>(version, data), Options));
|
||||||
|
File.Move(temp, path, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a save file. Absent when there is no file; Corrupt when there is one but it cannot be
|
||||||
|
/// turned into <typeparamref name="TAggregate"/> — unreadable JSON, an unknown version, or a
|
||||||
|
/// snapshot the domain rejects. Never conflates the two: that conflation is what let a version
|
||||||
|
/// bump silently overwrite a player's save.
|
||||||
|
/// </summary>
|
||||||
|
internal static LoadResult<TAggregate> Read<TSnapshot, TAggregate>(
|
||||||
|
string path,
|
||||||
|
int currentVersion,
|
||||||
|
Func<TSnapshot, TAggregate> restore)
|
||||||
|
where TSnapshot : class
|
||||||
|
where TAggregate : class
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) return LoadResult<TAggregate>.Absent();
|
||||||
|
|
||||||
|
SaveEnvelope<TSnapshot>? envelope;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
envelope = JsonSerializer.Deserialize<SaveEnvelope<TSnapshot>>(File.ReadAllText(path), Options);
|
||||||
|
}
|
||||||
|
catch (JsonException e)
|
||||||
|
{
|
||||||
|
return LoadResult<TAggregate>.Corrupt($"unreadable json: {e.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope?.Data is null)
|
||||||
|
return LoadResult<TAggregate>.Corrupt("file held no save data");
|
||||||
|
|
||||||
|
if (envelope.Version > currentVersion)
|
||||||
|
return LoadResult<TAggregate>.Corrupt(
|
||||||
|
$"saved by a newer build (version {envelope.Version}, this build reads {currentVersion})");
|
||||||
|
|
||||||
|
if (envelope.Version < currentVersion)
|
||||||
|
return LoadResult<TAggregate>.Corrupt(
|
||||||
|
$"version {envelope.Version} has no migration to {currentVersion}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return LoadResult<TAggregate>.Loaded(restore(envelope.Data));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return LoadResult<TAggregate>.Corrupt($"snapshot rejected by the domain: {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
progression.adapters/JsonPlaythroughStore.cs
Normal file
30
progression.adapters/JsonPlaythroughStore.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Adapters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps each Playthrough in its own JSON file inside a directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Takes a directory path rather than using Godot's FileAccess. Godot resolves <c>user://</c> to an
|
||||||
|
/// absolute path with <c>ProjectSettings.GlobalizePath</c> and passes it in, so this whole class has
|
||||||
|
/// no engine dependency and can be tested against a real temp directory.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class JsonPlaythroughStore : IPlaythroughStore
|
||||||
|
{
|
||||||
|
/// <summary>Bump only alongside a migration for the version being replaced.</summary>
|
||||||
|
private const int CurrentVersion = 1;
|
||||||
|
|
||||||
|
private readonly string _directory;
|
||||||
|
|
||||||
|
public JsonPlaythroughStore(string directory) => _directory = directory;
|
||||||
|
|
||||||
|
/// <summary>One file per slot, so saving a run never rewrites the Profile or another slot.</summary>
|
||||||
|
private string PathFor(PlaythroughId id) =>
|
||||||
|
Path.Combine(_directory, $"playthrough_{id.Value:N}.json");
|
||||||
|
|
||||||
|
public void Save(Playthrough playthrough) =>
|
||||||
|
JsonFiles.WriteAtomic(PathFor(playthrough.Id), CurrentVersion, playthrough.Export());
|
||||||
|
|
||||||
|
public LoadResult<Playthrough> Load(PlaythroughId id) =>
|
||||||
|
JsonFiles.Read<PlaythroughSnapshot, Playthrough>(
|
||||||
|
PathFor(id), CurrentVersion, Playthrough.Restore);
|
||||||
|
}
|
||||||
20
progression.adapters/JsonProfileStore.cs
Normal file
20
progression.adapters/JsonProfileStore.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Adapters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps the Profile in its own JSON file, beside the Playthrough files but written independently.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JsonProfileStore : IProfileStore
|
||||||
|
{
|
||||||
|
/// <summary>Versioned separately from Playthroughs — the two shapes drift at their own pace.</summary>
|
||||||
|
private const int CurrentVersion = 1;
|
||||||
|
|
||||||
|
private readonly string _path;
|
||||||
|
|
||||||
|
public JsonProfileStore(string directory) => _path = Path.Combine(directory, "profile.json");
|
||||||
|
|
||||||
|
public void Save(Profile profile) =>
|
||||||
|
JsonFiles.WriteAtomic(_path, CurrentVersion, profile.Export());
|
||||||
|
|
||||||
|
public LoadResult<Profile> Load() =>
|
||||||
|
JsonFiles.Read<ProfileSnapshot, Profile>(_path, CurrentVersion, Profile.Restore);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\progression\Mr.BrickAdventures.Progression.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>Mr.BrickAdventures.Progression.Adapters</RootNamespace>
|
||||||
|
|
||||||
|
<!-- Required on assemblies referenced by a Godot project. -->
|
||||||
|
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Deliberately no engine reference. These adapters take a plain directory path; Godot resolves
|
||||||
|
user:// to an absolute path and passes it in, which is what keeps them testable. -->
|
||||||
|
|
||||||
|
|
||||||
|
</Project>
|
||||||
10
progression.adapters/SaveEnvelope.cs
Normal file
10
progression.adapters/SaveEnvelope.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Adapters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What actually goes in a save file: a version alongside the snapshot.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The version lives here rather than in the snapshot record so the domain only ever knows the
|
||||||
|
/// current shape. Migration is entirely this layer's problem.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed record SaveEnvelope<T>(int Version, T Data);
|
||||||
0
progression.tests/.gdignore
Normal file
0
progression.tests/.gdignore
Normal file
98
progression.tests/AttemptTests.cs
Normal file
98
progression.tests/AttemptTests.cs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class AttemptTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void An_attempt_cannot_die_twice()
|
||||||
|
{
|
||||||
|
// Overlapping hazards in a kill zone, or a hazard re-firing during the two seconds before
|
||||||
|
// DeathScreen reloads the scene, must not cost two lives. The Attempt refuses the second
|
||||||
|
// death, so no caller has to remember to guard against it.
|
||||||
|
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||||
|
|
||||||
|
Assert.True(attempt.TryEndInDeath());
|
||||||
|
Assert.False(attempt.TryEndInDeath());
|
||||||
|
Assert.Equal(AttemptOutcome.Died, attempt.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_attempt_that_died_cannot_then_be_cleared()
|
||||||
|
{
|
||||||
|
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||||
|
attempt.TryEndInDeath();
|
||||||
|
|
||||||
|
Assert.False(attempt.TryEndInCompletion());
|
||||||
|
Assert.Equal(AttemptOutcome.Died, attempt.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_attempt_that_was_cleared_cannot_then_die()
|
||||||
|
{
|
||||||
|
// The reverse of the above. Together these say a terminal outcome is final, which is what
|
||||||
|
// lets both ExitDoorComponent and the console request completion without banking twice.
|
||||||
|
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||||
|
attempt.TryEndInCompletion();
|
||||||
|
|
||||||
|
Assert.False(attempt.TryEndInDeath());
|
||||||
|
Assert.Equal(AttemptOutcome.Cleared, attempt.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Coins_found_after_the_attempt_ended_do_not_count()
|
||||||
|
{
|
||||||
|
// The player node survives for two seconds after death while DeathScreen counts down, so a
|
||||||
|
// coin's Area2D can still fire. That is a real event rather than an error, and it must not
|
||||||
|
// enlarge a ledger that is already settled.
|
||||||
|
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||||
|
attempt.RecordCoinsFound(Coins.Of(3));
|
||||||
|
attempt.RecordCoinsFound(Coins.Of(2));
|
||||||
|
attempt.TryEndInDeath();
|
||||||
|
|
||||||
|
attempt.RecordCoinsFound(Coins.Of(100));
|
||||||
|
|
||||||
|
Assert.Equal(Coins.Of(5), attempt.CoinsFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_purchase_after_the_attempt_ended_is_refused()
|
||||||
|
{
|
||||||
|
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||||
|
attempt.TryEndInDeath();
|
||||||
|
|
||||||
|
var recorded = attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.Of(1), Coins.Of(10));
|
||||||
|
|
||||||
|
Assert.False(recorded);
|
||||||
|
Assert.Equal(Coins.Zero, attempt.CoinsSpent);
|
||||||
|
Assert.Empty(attempt.SkillsBought);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Spending_more_than_was_found_this_attempt_is_recorded_not_rejected()
|
||||||
|
{
|
||||||
|
// The shortfall is drawn from the Bank when the Attempt is committed. Rejecting it here
|
||||||
|
// would make the ledger enforce a rule it cannot see — affordability spans both scopes.
|
||||||
|
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||||
|
attempt.RecordCoinsFound(Coins.Of(10));
|
||||||
|
|
||||||
|
var recorded = attempt.TryRecordPurchase(SkillId.Of("magnetic"), SkillLevel.Of(1), Coins.Of(30));
|
||||||
|
|
||||||
|
Assert.True(recorded);
|
||||||
|
Assert.Equal(Coins.Of(10), attempt.CoinsFound);
|
||||||
|
Assert.Equal(Coins.Of(30), attempt.CoinsSpent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unlocking_then_upgrading_one_skill_records_the_level_it_ended_at()
|
||||||
|
{
|
||||||
|
// Both happen in the marketplace mid-Attempt. Commit applies one level per skill, so the
|
||||||
|
// ledger keeps where it ended up, while the costs accumulate.
|
||||||
|
var brickThrow = SkillId.Of("brick_throw");
|
||||||
|
var attempt = new Attempt(LevelId.Of("village_1"));
|
||||||
|
|
||||||
|
attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(1), Coins.Of(10));
|
||||||
|
attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(2), Coins.Of(25));
|
||||||
|
|
||||||
|
Assert.Equal(SkillLevel.Of(2), attempt.SkillsBought[brickThrow]);
|
||||||
|
Assert.Equal(Coins.Of(35), attempt.CoinsSpent);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
progression.tests/CoinsTests.cs
Normal file
23
progression.tests/CoinsTests.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class CoinsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Subtracting_more_than_held_fails_and_leaves_the_amount_untouched()
|
||||||
|
{
|
||||||
|
var held = Coins.Of(10);
|
||||||
|
|
||||||
|
var succeeded = held.TrySubtract(Coins.Of(30), out var remaining);
|
||||||
|
|
||||||
|
Assert.False(succeeded);
|
||||||
|
// Not clamped to zero. A caller that ignores the bool sees coins un-spent,
|
||||||
|
// which is the safe outcome; clamping would silently consume what wasn't there.
|
||||||
|
Assert.Equal(Coins.Of(10), remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_negative_quantity_of_coins_cannot_exist()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => Coins.Of(-1));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
progression.tests/GlobalUsings.cs
Normal file
1
progression.tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
34
progression.tests/InMemoryPlaythroughStore.cs
Normal file
34
progression.tests/InMemoryPlaythroughStore.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The test-side adapter for <see cref="IPlaythroughStore"/>. Its existence alongside the JSON store
|
||||||
|
/// is what makes the port a real seam rather than a hypothetical one.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class InMemoryPlaythroughStore : IPlaythroughStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Guid, PlaythroughSnapshot> _saved = new();
|
||||||
|
private readonly HashSet<Guid> _corrupt = new();
|
||||||
|
|
||||||
|
public void Save(Playthrough playthrough) => _saved[playthrough.Id.Value] = playthrough.Export();
|
||||||
|
|
||||||
|
/// <summary>Simulates a file that exists but cannot be read back into a valid snapshot.</summary>
|
||||||
|
public void Corrupt(PlaythroughId id) => _corrupt.Add(id.Value);
|
||||||
|
|
||||||
|
public LoadResult<Playthrough> Load(PlaythroughId id)
|
||||||
|
{
|
||||||
|
if (_corrupt.Contains(id.Value))
|
||||||
|
return LoadResult<Playthrough>.Corrupt("snapshot could not be read");
|
||||||
|
|
||||||
|
if (!_saved.TryGetValue(id.Value, out var snapshot))
|
||||||
|
return LoadResult<Playthrough>.Absent();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return LoadResult<Playthrough>.Loaded(Playthrough.Restore(snapshot));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return LoadResult<Playthrough>.Corrupt(e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
progression.tests/InMemoryProfileStore.cs
Normal file
26
progression.tests/InMemoryProfileStore.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
internal sealed class InMemoryProfileStore : IProfileStore
|
||||||
|
{
|
||||||
|
private ProfileSnapshot? _saved;
|
||||||
|
private bool _corrupt;
|
||||||
|
|
||||||
|
public void Save(Profile profile) => _saved = profile.Export();
|
||||||
|
|
||||||
|
public void Corrupt() => _corrupt = true;
|
||||||
|
|
||||||
|
public LoadResult<Profile> Load()
|
||||||
|
{
|
||||||
|
if (_corrupt) return LoadResult<Profile>.Corrupt("snapshot could not be read");
|
||||||
|
if (_saved is null) return LoadResult<Profile>.Absent();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return LoadResult<Profile>.Loaded(Profile.Restore(_saved));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return LoadResult<Profile>.Corrupt(e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
progression.tests/JsonPlaythroughStoreTests.cs
Normal file
121
progression.tests/JsonPlaythroughStoreTests.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using Mr.BrickAdventures.Progression.Adapters;
|
||||||
|
|
||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// These run against a real directory on disk. The store takes a path rather than using Godot's
|
||||||
|
/// FileAccess, which is the whole reason this is testable at all.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JsonPlaythroughStoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory =
|
||||||
|
Path.Combine(Path.GetTempPath(), "ppc-progression-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
private static readonly LevelId First = LevelId.Of("village_1");
|
||||||
|
private static readonly LevelId Second = LevelId.Of("village_2");
|
||||||
|
|
||||||
|
private static readonly PlaythroughId Id =
|
||||||
|
PlaythroughId.Of(new Guid("55555555-5555-5555-5555-555555555555"));
|
||||||
|
|
||||||
|
private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second });
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_playthrough_survives_a_round_trip_through_a_real_file()
|
||||||
|
{
|
||||||
|
var store = new JsonPlaythroughStore(_directory);
|
||||||
|
var campaign = TwoLevels();
|
||||||
|
var playthrough = Playthrough.StartNew(Id, campaign, Lives.Of(3));
|
||||||
|
var attempt = new Attempt(First);
|
||||||
|
attempt.RecordCoinsFound(Coins.Of(50));
|
||||||
|
attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.Of(2), Coins.Of(30));
|
||||||
|
playthrough.SettleCompletion(attempt, campaign);
|
||||||
|
|
||||||
|
store.Save(playthrough);
|
||||||
|
var result = store.Load(Id);
|
||||||
|
|
||||||
|
Assert.Equal(LoadOutcome.Loaded, result.Outcome);
|
||||||
|
Assert.Equal(Coins.Of(20), result.Value!.Bank);
|
||||||
|
Assert.Equal(Second, result.Value.CurrentLevel);
|
||||||
|
Assert.Equal(SkillLevel.Of(2), result.Value.OwnedSkills[SkillId.Of("brick_throw")]);
|
||||||
|
Assert.True(result.Value.HasCleared(First));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_file_means_absent()
|
||||||
|
{
|
||||||
|
Assert.Equal(LoadOutcome.Absent, new JsonPlaythroughStore(_directory).Load(Id).Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unreadable_json_is_corrupt_not_absent()
|
||||||
|
{
|
||||||
|
var store = new JsonPlaythroughStore(_directory);
|
||||||
|
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
|
||||||
|
File.WriteAllText(Directory.GetFiles(_directory, "*.json").Single(), "{ this is not json");
|
||||||
|
|
||||||
|
var result = store.Load(Id);
|
||||||
|
|
||||||
|
Assert.Equal(LoadOutcome.Corrupt, result.Outcome);
|
||||||
|
Assert.NotNull(result.Problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_save_from_a_newer_build_is_corrupt_rather_than_treated_as_no_save()
|
||||||
|
{
|
||||||
|
// The bug this replaces, exactly: SaveSystem compared versions with != and returned false,
|
||||||
|
// ContinueGame read false as "no save", called StartNewGame, and overwrote the file. Here the
|
||||||
|
// caller can tell the difference and say so instead.
|
||||||
|
var store = new JsonPlaythroughStore(_directory);
|
||||||
|
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
|
||||||
|
var file = Directory.GetFiles(_directory, "*.json").Single();
|
||||||
|
File.WriteAllText(file, File.ReadAllText(file).Replace("\"version\": 1", "\"version\": 99"));
|
||||||
|
|
||||||
|
var result = store.Load(Id);
|
||||||
|
|
||||||
|
Assert.Equal(LoadOutcome.Corrupt, result.Outcome);
|
||||||
|
Assert.Contains("newer build", result.Problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_snapshot_the_domain_rejects_is_corrupt()
|
||||||
|
{
|
||||||
|
var store = new JsonPlaythroughStore(_directory);
|
||||||
|
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
|
||||||
|
var file = Directory.GetFiles(_directory, "*.json").Single();
|
||||||
|
File.WriteAllText(file, File.ReadAllText(file).Replace("\"lives\": 3", "\"lives\": -1"));
|
||||||
|
|
||||||
|
Assert.Equal(LoadOutcome.Corrupt, store.Load(Id).Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saving_leaves_no_temporary_file_behind()
|
||||||
|
{
|
||||||
|
var store = new JsonPlaythroughStore(_directory);
|
||||||
|
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
|
||||||
|
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(2)));
|
||||||
|
|
||||||
|
Assert.Empty(Directory.GetFiles(_directory, "*.tmp"));
|
||||||
|
Assert.Single(Directory.GetFiles(_directory, "*.json"));
|
||||||
|
Assert.Equal(2, store.Load(Id).Value!.Lives.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Each_playthrough_gets_its_own_file_so_slots_do_not_disturb_each_other()
|
||||||
|
{
|
||||||
|
// The reason for a flat record per aggregate rather than one combined document.
|
||||||
|
var other = PlaythroughId.Of(new Guid("66666666-6666-6666-6666-666666666666"));
|
||||||
|
var store = new JsonPlaythroughStore(_directory);
|
||||||
|
|
||||||
|
store.Save(Playthrough.StartNew(Id, TwoLevels(), Lives.Of(3)));
|
||||||
|
store.Save(Playthrough.StartNew(other, TwoLevels(), Lives.Of(1)));
|
||||||
|
|
||||||
|
Assert.Equal(2, Directory.GetFiles(_directory, "*.json").Length);
|
||||||
|
Assert.Equal(3, store.Load(Id).Value!.Lives.Value);
|
||||||
|
Assert.Equal(1, store.Load(other).Value!.Lives.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
progression.tests/JsonProfileStoreTests.cs
Normal file
65
progression.tests/JsonProfileStoreTests.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
using Mr.BrickAdventures.Progression.Adapters;
|
||||||
|
|
||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public sealed class JsonProfileStoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory =
|
||||||
|
Path.Combine(Path.GetTempPath(), "ppc-progression-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
private static readonly LevelId Level = LevelId.Of("village_1");
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_profile_survives_a_round_trip_through_a_real_file()
|
||||||
|
{
|
||||||
|
var store = new JsonProfileStore(_directory);
|
||||||
|
var profile = Profile.Empty();
|
||||||
|
profile.RecordTime(Level, LevelTime.Of(31.25));
|
||||||
|
profile.UnlockAchievement(AchievementId.Of("rescue_ten_kids"));
|
||||||
|
profile.Record(StatName.Deaths, 7);
|
||||||
|
|
||||||
|
store.Save(profile);
|
||||||
|
var result = store.Load();
|
||||||
|
|
||||||
|
Assert.Equal(LoadOutcome.Loaded, result.Outcome);
|
||||||
|
Assert.Equal(LevelTime.Of(31.25), result.Value!.BestTimeFor(Level));
|
||||||
|
Assert.True(result.Value.HasAchievement(AchievementId.Of("rescue_ten_kids")));
|
||||||
|
Assert.Equal(7, result.Value.ReadStat(StatName.Deaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void On_a_first_run_there_is_no_profile_file_and_that_is_absent()
|
||||||
|
{
|
||||||
|
Assert.Equal(LoadOutcome.Absent, new JsonProfileStore(_directory).Load().Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saving_a_playthrough_does_not_touch_the_profile_file()
|
||||||
|
{
|
||||||
|
// The point of one file per aggregate. A run being saved on every level completion must not
|
||||||
|
// rewrite achievements and best times, and a corrupt slot must not cost them.
|
||||||
|
var profiles = new JsonProfileStore(_directory);
|
||||||
|
var playthroughs = new JsonPlaythroughStore(_directory);
|
||||||
|
var profile = Profile.Empty();
|
||||||
|
profile.RecordTime(Level, LevelTime.Of(31.25));
|
||||||
|
profiles.Save(profile);
|
||||||
|
|
||||||
|
var profilePath = Directory.GetFiles(_directory, "profile*.json").Single();
|
||||||
|
var before = File.GetLastWriteTimeUtc(profilePath);
|
||||||
|
var contentBefore = File.ReadAllText(profilePath);
|
||||||
|
|
||||||
|
playthroughs.Save(Playthrough.StartNew(
|
||||||
|
PlaythroughId.Of(Guid.NewGuid()),
|
||||||
|
Campaign.Of(new[] { Level }),
|
||||||
|
Lives.Of(3)));
|
||||||
|
|
||||||
|
Assert.Equal(before, File.GetLastWriteTimeUtc(profilePath));
|
||||||
|
Assert.Equal(contentBefore, File.ReadAllText(profilePath));
|
||||||
|
Assert.Equal(2, Directory.GetFiles(_directory, "*.json").Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
progression.tests/LevelIdTests.cs
Normal file
14
progression.tests/LevelIdTests.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class LevelIdTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void A_blank_id_is_not_a_level(string value)
|
||||||
|
{
|
||||||
|
// Levels are addressed by a stable authored id, never by position in a list. An unset
|
||||||
|
// field in a .tres would otherwise become a valid id that silently collides with itself.
|
||||||
|
Assert.Throws<ArgumentException>(() => LevelId.Of(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
23
progression.tests/LevelTimeTests.cs
Normal file
23
progression.tests/LevelTimeTests.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class LevelTimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Zero_is_not_a_completion_time()
|
||||||
|
{
|
||||||
|
// GetCurrentLevelTime() returns 0 whenever the speedrun timer was never started, which
|
||||||
|
// today is every continued run — so zero-second "best times" get written to the ghost
|
||||||
|
// files. Rejecting zero makes that unrepresentable rather than merely unlikely.
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => LevelTime.Of(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(double.NaN)]
|
||||||
|
[InlineData(double.PositiveInfinity)]
|
||||||
|
public void A_time_that_is_not_a_finite_number_is_rejected(double seconds)
|
||||||
|
{
|
||||||
|
// Times are derived from subtracting tick counts, so a stale or unset start time can
|
||||||
|
// produce NaN or infinity rather than a plausible-but-wrong number.
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => LevelTime.Of(seconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
21
progression.tests/LivesTests.cs
Normal file
21
progression.tests/LivesTests.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class LivesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Losing_the_last_life_exhausts_them()
|
||||||
|
{
|
||||||
|
var remaining = Lives.Of(1).Decrement();
|
||||||
|
|
||||||
|
Assert.Equal(0, remaining.Value);
|
||||||
|
Assert.True(remaining.IsExhausted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrementing_when_already_exhausted_stays_at_zero()
|
||||||
|
{
|
||||||
|
var remaining = Lives.Of(0).Decrement();
|
||||||
|
|
||||||
|
Assert.Equal(0, remaining.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>Mr.BrickAdventures.Progression.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\progression\Mr.BrickAdventures.Progression.csproj" />
|
||||||
|
<ProjectReference Include="..\progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.4.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
13
progression.tests/PlaythroughIdTests.cs
Normal file
13
progression.tests/PlaythroughIdTests.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class PlaythroughIdTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void The_empty_guid_is_not_a_playthrough()
|
||||||
|
{
|
||||||
|
// Ids arrive from an adapter, never from Guid.NewGuid() inside the domain — otherwise every
|
||||||
|
// test touching identity would be non-deterministic. That makes a default-valued guid a
|
||||||
|
// realistic mistake worth refusing.
|
||||||
|
Assert.Throws<ArgumentException>(() => PlaythroughId.Of(Guid.Empty));
|
||||||
|
}
|
||||||
|
}
|
||||||
33
progression.tests/PlaythroughSnapshotTests.cs
Normal file
33
progression.tests/PlaythroughSnapshotTests.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class PlaythroughSnapshotTests
|
||||||
|
{
|
||||||
|
private static readonly LevelId First = LevelId.Of("village_1");
|
||||||
|
private static readonly LevelId Second = LevelId.Of("village_2");
|
||||||
|
|
||||||
|
private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second });
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_playthrough_survives_a_round_trip_through_its_snapshot()
|
||||||
|
{
|
||||||
|
var brickThrow = SkillId.Of("brick_throw");
|
||||||
|
var campaign = TwoLevels();
|
||||||
|
var original = Playthrough.StartNew(
|
||||||
|
PlaythroughId.Of(new Guid("22222222-2222-2222-2222-222222222222")), campaign, Lives.Of(3));
|
||||||
|
|
||||||
|
var attempt = new Attempt(First);
|
||||||
|
attempt.RecordCoinsFound(Coins.Of(50));
|
||||||
|
attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(2), Coins.Of(30));
|
||||||
|
original.SettleCompletion(attempt, campaign);
|
||||||
|
original.SettleDeath(new Attempt(Second));
|
||||||
|
|
||||||
|
var restored = Playthrough.Restore(original.Export());
|
||||||
|
|
||||||
|
Assert.Equal(original.Id, restored.Id);
|
||||||
|
Assert.Equal(original.Lives.Value, restored.Lives.Value);
|
||||||
|
Assert.Equal(original.Bank, restored.Bank);
|
||||||
|
Assert.Equal(original.CurrentLevel, restored.CurrentLevel);
|
||||||
|
Assert.Equal(SkillLevel.Of(2), restored.OwnedSkills[brickThrow]);
|
||||||
|
Assert.Equal(original.ClearedLevels.OrderBy(l => l.Value), restored.ClearedLevels.OrderBy(l => l.Value));
|
||||||
|
}
|
||||||
|
}
|
||||||
47
progression.tests/PlaythroughStoreTests.cs
Normal file
47
progression.tests/PlaythroughStoreTests.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class PlaythroughStoreTests
|
||||||
|
{
|
||||||
|
private static readonly PlaythroughId Id =
|
||||||
|
PlaythroughId.Of(new Guid("33333333-3333-3333-3333-333333333333"));
|
||||||
|
|
||||||
|
private static Campaign OneLevel() => Campaign.Of(new[] { LevelId.Of("village_1") });
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_playthrough_that_was_never_saved_is_absent_not_an_error()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPlaythroughStore();
|
||||||
|
|
||||||
|
var result = store.Load(Id);
|
||||||
|
|
||||||
|
Assert.Equal(LoadOutcome.Absent, result.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_playthrough_that_cannot_be_read_is_corrupt_and_not_mistaken_for_absent()
|
||||||
|
{
|
||||||
|
// This is the version-bump bug. LoadGame returns false for both "no file" and "version
|
||||||
|
// mismatch", ContinueGame reads that as no save, calls StartNewGame, and overwrites the
|
||||||
|
// player's file. Absent means "offer a new game"; Corrupt means "say something went wrong".
|
||||||
|
var store = new InMemoryPlaythroughStore();
|
||||||
|
store.Save(Playthrough.StartNew(Id, OneLevel(), Lives.Of(3)));
|
||||||
|
store.Corrupt(Id);
|
||||||
|
|
||||||
|
var result = store.Load(Id);
|
||||||
|
|
||||||
|
Assert.Equal(LoadOutcome.Corrupt, result.Outcome);
|
||||||
|
Assert.NotEqual(LoadOutcome.Absent, result.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_saved_playthrough_loads_back()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPlaythroughStore();
|
||||||
|
store.Save(Playthrough.StartNew(Id, OneLevel(), Lives.Of(2)));
|
||||||
|
|
||||||
|
var result = store.Load(Id);
|
||||||
|
|
||||||
|
Assert.Equal(LoadOutcome.Loaded, result.Outcome);
|
||||||
|
Assert.Equal(2, result.Value!.Lives.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
173
progression.tests/PlaythroughTests.cs
Normal file
173
progression.tests/PlaythroughTests.cs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class PlaythroughTests
|
||||||
|
{
|
||||||
|
private static readonly LevelId First = LevelId.Of("village_1");
|
||||||
|
private static readonly LevelId Second = LevelId.Of("village_2");
|
||||||
|
private static readonly LevelId Third = LevelId.Of("village_3");
|
||||||
|
|
||||||
|
private static readonly PlaythroughId Id =
|
||||||
|
PlaythroughId.Of(new Guid("11111111-1111-1111-1111-111111111111"));
|
||||||
|
|
||||||
|
private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second });
|
||||||
|
private static Campaign ThreeLevels() => Campaign.Of(new[] { First, Second, Third });
|
||||||
|
|
||||||
|
private static Playthrough StartWith(Lives lives) => Playthrough.StartNew(Id, TwoLevels(), lives);
|
||||||
|
|
||||||
|
private static Playthrough StartWith(Lives lives, Campaign campaign) =>
|
||||||
|
Playthrough.StartNew(Id, campaign, lives);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Losing_the_last_life_exhausts_lives_and_announces_that_the_run_ended()
|
||||||
|
{
|
||||||
|
// The soft-lock this replaces: DeathScreen and GameOverScreen are both wired to
|
||||||
|
// HealthComponent.Death by scene connections, which fire before PlayerController._Ready
|
||||||
|
// attaches the lambda that decrements lives. So on the last life GameOverScreen sees 1,
|
||||||
|
// shows nothing, and DeathScreen then sees 0 and refuses to reload. Settling is one
|
||||||
|
// operation, so there is no window in which a reader can see a stale value.
|
||||||
|
var playthrough = StartWith(Lives.Of(1));
|
||||||
|
var attempt = new Attempt(playthrough.CurrentLevel);
|
||||||
|
|
||||||
|
playthrough.SettleDeath(attempt);
|
||||||
|
|
||||||
|
Assert.True(playthrough.Lives.IsExhausted);
|
||||||
|
Assert.True(playthrough.IsRunOver);
|
||||||
|
Assert.Contains(playthrough.DrainEvents(), e => e is RunEnded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Two_deaths_in_one_attempt_cost_one_life()
|
||||||
|
{
|
||||||
|
// A kill zone with overlapping hazards, or a hazard re-firing during the two seconds before
|
||||||
|
// the scene reloads. Lives flooring at zero cannot catch this — 3 to 1 is just as wrong.
|
||||||
|
var playthrough = StartWith(Lives.Of(3));
|
||||||
|
var attempt = new Attempt(playthrough.CurrentLevel);
|
||||||
|
|
||||||
|
playthrough.SettleDeath(attempt);
|
||||||
|
playthrough.SettleDeath(attempt);
|
||||||
|
|
||||||
|
Assert.Equal(2, playthrough.Lives.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dying_after_buying_a_skill_costs_neither_the_coins_nor_the_skill()
|
||||||
|
{
|
||||||
|
// Today RemoveCoins drains session coins and then debits Player.Coins immediately, while
|
||||||
|
// ResetSession throws the skill away — so buying mid-level and dying loses both. Under the
|
||||||
|
// ledger the purchase was never applied, so there is nothing to lose.
|
||||||
|
var playthrough = StartWith(Lives.Of(3));
|
||||||
|
var attempt = new Attempt(playthrough.CurrentLevel);
|
||||||
|
attempt.RecordCoinsFound(Coins.Of(50));
|
||||||
|
attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.Of(1), Coins.Of(40));
|
||||||
|
|
||||||
|
playthrough.SettleDeath(attempt);
|
||||||
|
|
||||||
|
// Had the ledger been applied this would be 10, so zero is a real assertion rather than
|
||||||
|
// just the starting value.
|
||||||
|
Assert.Equal(Coins.Zero, playthrough.Bank);
|
||||||
|
Assert.Empty(playthrough.OwnedSkills);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_first_clear_banks_the_ledger_owns_the_skills_and_advances()
|
||||||
|
{
|
||||||
|
var brickThrow = SkillId.Of("brick_throw");
|
||||||
|
var playthrough = StartWith(Lives.Of(3));
|
||||||
|
var attempt = new Attempt(playthrough.CurrentLevel);
|
||||||
|
attempt.RecordCoinsFound(Coins.Of(50));
|
||||||
|
attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(1), Coins.Of(40));
|
||||||
|
|
||||||
|
playthrough.SettleCompletion(attempt, TwoLevels());
|
||||||
|
|
||||||
|
Assert.Equal(Coins.Of(10), playthrough.Bank);
|
||||||
|
Assert.Equal(SkillLevel.Of(1), playthrough.OwnedSkills[brickThrow]);
|
||||||
|
Assert.True(playthrough.HasCleared(First));
|
||||||
|
Assert.Equal(Second, playthrough.CurrentLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_replay_banks_nothing_and_never_moves_campaign_position_backwards()
|
||||||
|
{
|
||||||
|
// ADR-0003. The dangerous version of this bug is not "no reward" — it is a completionist
|
||||||
|
// replaying level 1 while on level 3 and being sent back to level 2.
|
||||||
|
var campaign = ThreeLevels();
|
||||||
|
var playthrough = StartWith(Lives.Of(3), campaign);
|
||||||
|
playthrough.SettleCompletion(new Attempt(First), campaign);
|
||||||
|
playthrough.SettleCompletion(new Attempt(Second), campaign);
|
||||||
|
|
||||||
|
var replay = new Attempt(First);
|
||||||
|
replay.RecordCoinsFound(Coins.Of(99));
|
||||||
|
playthrough.SettleCompletion(replay, campaign);
|
||||||
|
|
||||||
|
Assert.Equal(Coins.Zero, playthrough.Bank);
|
||||||
|
Assert.Equal(Third, playthrough.CurrentLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reachable_levels_are_derived_from_cleared_levels()
|
||||||
|
{
|
||||||
|
// Replaces the stored UnlockedLevels list. That list was kept in sync only by
|
||||||
|
// MarkLevelComplete while UnlockLevel stayed public, so the two could diverge — a derived
|
||||||
|
// fact stored beside its own source.
|
||||||
|
var campaign = ThreeLevels();
|
||||||
|
var playthrough = StartWith(Lives.Of(3), campaign);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { First }, playthrough.ReachableLevels(campaign).OrderBy(l => l.Value));
|
||||||
|
|
||||||
|
playthrough.SettleCompletion(new Attempt(First), campaign);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { First, Second }, playthrough.ReachableLevels(campaign).OrderBy(l => l.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Affordability_counts_the_bank_plus_this_attempt_minus_what_it_already_spent()
|
||||||
|
{
|
||||||
|
var campaign = TwoLevels();
|
||||||
|
var playthrough = StartWith(Lives.Of(3));
|
||||||
|
|
||||||
|
var banking = new Attempt(First);
|
||||||
|
banking.RecordCoinsFound(Coins.Of(20));
|
||||||
|
playthrough.SettleCompletion(banking, campaign);
|
||||||
|
|
||||||
|
var attempt = new Attempt(Second);
|
||||||
|
attempt.RecordCoinsFound(Coins.Of(10));
|
||||||
|
Assert.True(playthrough.CanAfford(attempt, Coins.Of(30)));
|
||||||
|
|
||||||
|
attempt.TryRecordPurchase(SkillId.Of("magnetic"), SkillLevel.Of(1), Coins.Of(25));
|
||||||
|
|
||||||
|
Assert.True(playthrough.CanAfford(attempt, Coins.Of(5)));
|
||||||
|
Assert.False(playthrough.CanAfford(attempt, Coins.Of(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_attempt_that_died_cannot_later_be_settled_as_a_completion()
|
||||||
|
{
|
||||||
|
// Both settle operations defer to the Attempt's terminal outcome, so a completion arriving
|
||||||
|
// after a death — an exit door triggering as the player dies on it — banks nothing.
|
||||||
|
var campaign = TwoLevels();
|
||||||
|
var playthrough = StartWith(Lives.Of(3));
|
||||||
|
var attempt = new Attempt(First);
|
||||||
|
attempt.RecordCoinsFound(Coins.Of(50));
|
||||||
|
playthrough.SettleDeath(attempt);
|
||||||
|
|
||||||
|
playthrough.SettleCompletion(attempt, campaign);
|
||||||
|
|
||||||
|
Assert.Equal(Coins.Zero, playthrough.Bank);
|
||||||
|
Assert.False(playthrough.HasCleared(First));
|
||||||
|
Assert.Equal(First, playthrough.CurrentLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Committing_an_attempt_that_overspent_the_bank_is_an_impossible_state()
|
||||||
|
{
|
||||||
|
// PurchaseSkill's affordability check makes this unreachable through the application layer.
|
||||||
|
// Asserted directly so the guard is covered rather than being dead code nobody has run — if
|
||||||
|
// it ever fires, the bug is a missing affordability check, not this line.
|
||||||
|
var campaign = TwoLevels();
|
||||||
|
var playthrough = StartWith(Lives.Of(3));
|
||||||
|
var attempt = new Attempt(First);
|
||||||
|
attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.First, Coins.Of(999));
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => playthrough.SettleCompletion(attempt, campaign));
|
||||||
|
}
|
||||||
|
}
|
||||||
52
progression.tests/ProfileSnapshotTests.cs
Normal file
52
progression.tests/ProfileSnapshotTests.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class ProfileSnapshotTests
|
||||||
|
{
|
||||||
|
private static readonly LevelId Level = LevelId.Of("village_1");
|
||||||
|
private static readonly AchievementId RescueTen = AchievementId.Of("rescue_ten_kids");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_profile_survives_a_round_trip_through_its_snapshot()
|
||||||
|
{
|
||||||
|
var profile = Profile.Empty();
|
||||||
|
profile.RecordTime(Level, LevelTime.Of(42.5));
|
||||||
|
profile.UnlockAchievement(RescueTen);
|
||||||
|
profile.Record(StatName.Deaths, 7);
|
||||||
|
|
||||||
|
var restored = Profile.Restore(profile.Export());
|
||||||
|
|
||||||
|
Assert.Equal(LevelTime.Of(42.5), restored.BestTimeFor(Level));
|
||||||
|
Assert.True(restored.HasAchievement(RescueTen));
|
||||||
|
Assert.Equal(7, restored.ReadStat(StatName.Deaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Restoring_a_profile_does_not_re_announce_its_achievements()
|
||||||
|
{
|
||||||
|
// If Restore replayed UnlockAchievement it would raise AchievementUnlocked for every
|
||||||
|
// achievement on every load — so the popup would fire on startup and Steam would be
|
||||||
|
// re-pushed each time. Loading is not earning.
|
||||||
|
var profile = Profile.Empty();
|
||||||
|
profile.UnlockAchievement(RescueTen);
|
||||||
|
|
||||||
|
var restored = Profile.Restore(profile.Export());
|
||||||
|
|
||||||
|
Assert.Empty(restored.DrainEvents());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_unknown_statistic_is_dropped_rather_than_failing_the_load()
|
||||||
|
{
|
||||||
|
// A save written by a build that tracked a statistic this one no longer has. Losing one
|
||||||
|
// counter is acceptable; refusing to load the Profile — and with it every achievement and
|
||||||
|
// best time — is not.
|
||||||
|
var snapshot = new ProfileSnapshot(
|
||||||
|
BestTimes: new Dictionary<string, double>(),
|
||||||
|
Achievements: Array.Empty<string>(),
|
||||||
|
Statistics: new Dictionary<string, int> { ["Deaths"] = 4, ["BricksThrown"] = 99 });
|
||||||
|
|
||||||
|
var restored = Profile.Restore(snapshot);
|
||||||
|
|
||||||
|
Assert.Equal(4, restored.ReadStat(StatName.Deaths));
|
||||||
|
}
|
||||||
|
}
|
||||||
38
progression.tests/ProfileStoreTests.cs
Normal file
38
progression.tests/ProfileStoreTests.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class ProfileStoreTests
|
||||||
|
{
|
||||||
|
private static readonly LevelId Level = LevelId.Of("village_1");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void On_a_first_run_there_is_no_profile_yet_and_that_is_absent_not_corrupt()
|
||||||
|
{
|
||||||
|
var store = new InMemoryProfileStore();
|
||||||
|
|
||||||
|
Assert.Equal(LoadOutcome.Absent, store.Load().Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_saved_profile_loads_back_with_its_best_times()
|
||||||
|
{
|
||||||
|
var store = new InMemoryProfileStore();
|
||||||
|
var profile = Profile.Empty();
|
||||||
|
profile.RecordTime(Level, LevelTime.Of(31.25));
|
||||||
|
store.Save(profile);
|
||||||
|
|
||||||
|
var result = store.Load();
|
||||||
|
|
||||||
|
Assert.Equal(LoadOutcome.Loaded, result.Outcome);
|
||||||
|
Assert.Equal(LevelTime.Of(31.25), result.Value!.BestTimeFor(Level));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_profile_that_cannot_be_read_is_corrupt_so_achievements_are_not_silently_wiped()
|
||||||
|
{
|
||||||
|
var store = new InMemoryProfileStore();
|
||||||
|
store.Save(Profile.Empty());
|
||||||
|
store.Corrupt();
|
||||||
|
|
||||||
|
Assert.Equal(LoadOutcome.Corrupt, store.Load().Outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
progression.tests/ProfileTests.cs
Normal file
74
progression.tests/ProfileTests.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class ProfileTests
|
||||||
|
{
|
||||||
|
private static readonly LevelId Level = LevelId.Of("village_1");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_slower_run_does_not_replace_a_better_time()
|
||||||
|
{
|
||||||
|
var profile = Profile.Empty();
|
||||||
|
|
||||||
|
Assert.True(profile.RecordTime(Level, LevelTime.Of(42.5)));
|
||||||
|
Assert.False(profile.RecordTime(Level, LevelTime.Of(51.0)));
|
||||||
|
|
||||||
|
Assert.Equal(LevelTime.Of(42.5), profile.BestTimeFor(Level));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unlocking_the_same_achievement_twice_announces_it_once()
|
||||||
|
{
|
||||||
|
// AchievementManager shows a popup and pushes to Steam on every unlock call, guarded only by
|
||||||
|
// a Contains check on a list it reached into. Announcing only on the transition means
|
||||||
|
// subscribers cannot double-fire regardless of how often they are told.
|
||||||
|
var rescueTen = AchievementId.Of("rescue_ten_kids");
|
||||||
|
var profile = Profile.Empty();
|
||||||
|
|
||||||
|
profile.UnlockAchievement(rescueTen);
|
||||||
|
profile.UnlockAchievement(rescueTen);
|
||||||
|
|
||||||
|
Assert.True(profile.HasAchievement(rescueTen));
|
||||||
|
Assert.Single(profile.DrainEvents().OfType<AchievementUnlocked>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Statistics_accumulate_and_an_unrecorded_one_reads_as_zero()
|
||||||
|
{
|
||||||
|
var profile = Profile.Empty();
|
||||||
|
|
||||||
|
profile.Record(StatName.CoinsCollected, 5);
|
||||||
|
profile.Record(StatName.CoinsCollected, 3);
|
||||||
|
|
||||||
|
Assert.Equal(8, profile.ReadStat(StatName.CoinsCollected));
|
||||||
|
Assert.Equal(0, profile.ReadStat(StatName.Deaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_statistic_cannot_be_decremented()
|
||||||
|
{
|
||||||
|
// IncrementStat currently takes any int. A lifetime counter that can go down is not a
|
||||||
|
// lifetime counter, and nothing in the game has a reason to reduce one.
|
||||||
|
var profile = Profile.Empty();
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => profile.Record(StatName.Deaths, -1));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => profile.Record(StatName.Deaths, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Relocking_an_achievement_removes_it_and_lets_it_announce_again()
|
||||||
|
{
|
||||||
|
// Console-only. Note the asymmetry: unlocking is mirrored to Steam by a subscriber, but
|
||||||
|
// relocking cannot be, because a Steam achievement cannot be un-earned.
|
||||||
|
var rescueTen = AchievementId.Of("rescue_ten_kids");
|
||||||
|
var profile = Profile.Empty();
|
||||||
|
profile.UnlockAchievement(rescueTen);
|
||||||
|
profile.DrainEvents();
|
||||||
|
|
||||||
|
profile.RelockAchievement(rescueTen);
|
||||||
|
|
||||||
|
Assert.False(profile.HasAchievement(rescueTen));
|
||||||
|
|
||||||
|
profile.UnlockAchievement(rescueTen);
|
||||||
|
Assert.Single(profile.DrainEvents().OfType<AchievementUnlocked>());
|
||||||
|
}
|
||||||
|
}
|
||||||
370
progression.tests/ProgressionServiceTests.cs
Normal file
370
progression.tests/ProgressionServiceTests.cs
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class ProgressionServiceTests
|
||||||
|
{
|
||||||
|
private static readonly LevelId First = LevelId.Of("village_1");
|
||||||
|
private static readonly LevelId Second = LevelId.Of("village_2");
|
||||||
|
|
||||||
|
private static readonly PlaythroughId Id =
|
||||||
|
PlaythroughId.Of(new Guid("44444444-4444-4444-4444-444444444444"));
|
||||||
|
|
||||||
|
private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second });
|
||||||
|
|
||||||
|
private static ProgressionService Service()
|
||||||
|
{
|
||||||
|
var service = new ProgressionService(
|
||||||
|
Profile.Empty(),
|
||||||
|
new InMemoryPlaythroughStore(),
|
||||||
|
new InMemoryProfileStore(),
|
||||||
|
TwoLevels(),
|
||||||
|
SkillCatalogue.Empty(),
|
||||||
|
startingLives: Lives.Of(3));
|
||||||
|
|
||||||
|
service.StartPlaythrough(Id);
|
||||||
|
service.DrainEvents(); // setup announces; tests assert about the operation under test
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Entering_a_level_always_announces_that_it_started()
|
||||||
|
{
|
||||||
|
// Today LevelStarted is emitted only by TryToGoToNextLevel, so it never fires for the first
|
||||||
|
// level of a new game or for anything after a Continue. GhostManager.StartRecording and the
|
||||||
|
// speedrun timer both hang off it, which is why neither works except mid-campaign.
|
||||||
|
var service = Service();
|
||||||
|
|
||||||
|
service.EnterLevel(First);
|
||||||
|
|
||||||
|
Assert.Contains(service.DrainEvents(), e => e is LevelStarted started && started.Level.Equals(First));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Entering_a_level_the_playthrough_cannot_reach_is_refused()
|
||||||
|
{
|
||||||
|
var service = Service();
|
||||||
|
|
||||||
|
Assert.False(service.EnterLevel(Second));
|
||||||
|
Assert.Empty(service.DrainEvents());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Two_death_events_in_one_attempt_cost_one_life_and_persist_the_result()
|
||||||
|
{
|
||||||
|
// End to end this time. Also note the save: today nothing persists on death, so quitting
|
||||||
|
// after dying gives the lives back.
|
||||||
|
var store = new InMemoryPlaythroughStore();
|
||||||
|
var service = new ProgressionService(
|
||||||
|
Profile.Empty(), store, new InMemoryProfileStore(),
|
||||||
|
TwoLevels(), SkillCatalogue.Empty(), Lives.Of(3));
|
||||||
|
service.StartPlaythrough(Id);
|
||||||
|
service.EnterLevel(First);
|
||||||
|
|
||||||
|
service.HandlePlayerDeath();
|
||||||
|
service.HandlePlayerDeath();
|
||||||
|
|
||||||
|
var reloaded = store.Load(Id);
|
||||||
|
Assert.Equal(LoadOutcome.Loaded, reloaded.Outcome);
|
||||||
|
Assert.Equal(2, reloaded.Value!.Lives.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly SkillId BrickThrow = SkillId.Of("brick_throw");
|
||||||
|
private static readonly SkillId BrickShield = SkillId.Of("brick_shield");
|
||||||
|
|
||||||
|
/// <summary>brick_throw costs 10 then 25; brick_shield has no authored upgrades at all.</summary>
|
||||||
|
private static SkillCatalogue Skills() => SkillCatalogue.Of(
|
||||||
|
new Dictionary<SkillId, IReadOnlyList<Coins>>
|
||||||
|
{
|
||||||
|
[BrickThrow] = new[] { Coins.Of(10), Coins.Of(25) },
|
||||||
|
[BrickShield] = Array.Empty<Coins>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static ProgressionService ServiceWithSkills(IPlaythroughStore? store = null)
|
||||||
|
{
|
||||||
|
var service = new ProgressionService(
|
||||||
|
Profile.Empty(), store ?? new InMemoryPlaythroughStore(), new InMemoryProfileStore(),
|
||||||
|
TwoLevels(), Skills(), Lives.Of(3));
|
||||||
|
service.StartPlaythrough(Id);
|
||||||
|
service.EnterLevel(First);
|
||||||
|
service.DrainEvents(); // setup announces; tests assert about the operation under test
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_skill_that_cannot_be_afforded_is_refused_and_records_nothing()
|
||||||
|
{
|
||||||
|
// The bank is empty and the attempt has found nothing, so there is no way to pay. Refusing
|
||||||
|
// here is what keeps SettleCompletion's negative-bank assertion unreachable.
|
||||||
|
var service = ServiceWithSkills();
|
||||||
|
|
||||||
|
Assert.Equal(PurchaseOutcome.NotAffordable, service.PurchaseSkill(BrickThrow));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_skill_with_no_authored_upgrades_cannot_be_bought()
|
||||||
|
{
|
||||||
|
// Five skills currently have no upgrades/ entries. TryUnlockSkill reads Upgrades[0].Cost on
|
||||||
|
// them, which would throw — it is unreachable today only because MarketplaceButton hides
|
||||||
|
// them. Here it is a stated outcome instead of a hidden landmine.
|
||||||
|
var service = ServiceWithSkills();
|
||||||
|
|
||||||
|
Assert.Equal(PurchaseOutcome.NotPurchasable, service.PurchaseSkill(BrickShield));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_skill_already_at_its_top_level_cannot_be_upgraded_further()
|
||||||
|
{
|
||||||
|
// TryUpgradeSkill guards with Level >= MaxLevel but then indexes Upgrades[skill.Level], and
|
||||||
|
// BrickArmorSkillComponent indexes Upgrades[Level - 1] unguarded.
|
||||||
|
var service = ServiceWithSkills();
|
||||||
|
service.RecordCoinsFound(Coins.Of(35));
|
||||||
|
|
||||||
|
// Unlock to level 1 for 10, upgrade to level 2 for 25 — both within one Attempt, so this
|
||||||
|
// also covers reading the current level from the ledger rather than only from the Playthrough.
|
||||||
|
Assert.Equal(PurchaseOutcome.Purchased, service.PurchaseSkill(BrickThrow));
|
||||||
|
Assert.Equal(PurchaseOutcome.Purchased, service.PurchaseSkill(BrickThrow));
|
||||||
|
|
||||||
|
Assert.Equal(PurchaseOutcome.AlreadyAtTopLevel, service.PurchaseSkill(BrickThrow));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Finishing_a_level_for_the_first_time_banks_advances_and_persists_both_stores()
|
||||||
|
{
|
||||||
|
var playthroughStore = new InMemoryPlaythroughStore();
|
||||||
|
var profileStore = new InMemoryProfileStore();
|
||||||
|
var service = new ProgressionService(
|
||||||
|
Profile.Empty(), playthroughStore, profileStore, TwoLevels(), Skills(), Lives.Of(3));
|
||||||
|
service.StartPlaythrough(Id);
|
||||||
|
service.EnterLevel(First);
|
||||||
|
service.RecordCoinsFound(Coins.Of(50));
|
||||||
|
|
||||||
|
service.FinishLevel(LevelTime.Of(31.5));
|
||||||
|
|
||||||
|
var reloaded = playthroughStore.Load(Id).Value!;
|
||||||
|
Assert.Equal(Coins.Of(50), reloaded.Bank);
|
||||||
|
Assert.Equal(Second, reloaded.CurrentLevel);
|
||||||
|
Assert.True(reloaded.HasCleared(First));
|
||||||
|
Assert.Equal(LevelTime.Of(31.5), profileStore.Load().Value!.BestTimeFor(First));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Replaying_a_cleared_level_improves_the_best_time_but_banks_nothing()
|
||||||
|
{
|
||||||
|
var playthroughStore = new InMemoryPlaythroughStore();
|
||||||
|
var profileStore = new InMemoryProfileStore();
|
||||||
|
var service = new ProgressionService(
|
||||||
|
Profile.Empty(), playthroughStore, profileStore, TwoLevels(), Skills(), Lives.Of(3));
|
||||||
|
service.StartPlaythrough(Id);
|
||||||
|
|
||||||
|
service.EnterLevel(First);
|
||||||
|
service.RecordCoinsFound(Coins.Of(50));
|
||||||
|
service.FinishLevel(LevelTime.Of(40.0));
|
||||||
|
|
||||||
|
// Back for a faster run, picking up coins along the way.
|
||||||
|
service.EnterLevel(First);
|
||||||
|
service.RecordCoinsFound(Coins.Of(99));
|
||||||
|
service.FinishLevel(LevelTime.Of(30.0));
|
||||||
|
|
||||||
|
var reloaded = playthroughStore.Load(Id).Value!;
|
||||||
|
Assert.Equal(Coins.Of(50), reloaded.Bank);
|
||||||
|
Assert.Equal(Second, reloaded.CurrentLevel);
|
||||||
|
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 A_settled_death_announces_the_outcome_so_no_screen_has_to_read_lives_itself()
|
||||||
|
{
|
||||||
|
// DeathScreen and GameOverScreen were both wired to HealthComponent.Death by scene
|
||||||
|
// connections that fire before lives are decremented, so on the last life neither saw zero.
|
||||||
|
// Announcing the settled result means the value is already correct when they hear about it.
|
||||||
|
var service = ServiceWithSkills();
|
||||||
|
|
||||||
|
service.HandlePlayerDeath();
|
||||||
|
|
||||||
|
var failed = service.DrainEvents().OfType<AttemptFailed>().Single();
|
||||||
|
Assert.Equal(2, failed.Remaining.Value);
|
||||||
|
Assert.False(failed.RunOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dying_on_the_last_life_announces_the_run_is_over_in_the_same_breath()
|
||||||
|
{
|
||||||
|
var service = new ProgressionService(
|
||||||
|
Profile.Empty(), new InMemoryPlaythroughStore(), new InMemoryProfileStore(),
|
||||||
|
TwoLevels(), Skills(), Lives.Of(1));
|
||||||
|
service.StartPlaythrough(Id);
|
||||||
|
service.EnterLevel(First);
|
||||||
|
service.DrainEvents();
|
||||||
|
|
||||||
|
service.HandlePlayerDeath();
|
||||||
|
|
||||||
|
var events = service.DrainEvents();
|
||||||
|
Assert.True(events.OfType<AttemptFailed>().Single().RunOver);
|
||||||
|
Assert.Single(events.OfType<RunEnded>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_repeated_death_event_announces_nothing_further()
|
||||||
|
{
|
||||||
|
var service = ServiceWithSkills();
|
||||||
|
service.HandlePlayerDeath();
|
||||||
|
service.DrainEvents();
|
||||||
|
|
||||||
|
service.HandlePlayerDeath();
|
||||||
|
|
||||||
|
Assert.Empty(service.DrainEvents().OfType<AttemptFailed>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_collected_skill_is_granted_free_and_only_once()
|
||||||
|
{
|
||||||
|
// Skill pickups are not purchases — no coins change hands. Collecting one you already own is
|
||||||
|
// a no-op rather than a free upgrade.
|
||||||
|
var service = ServiceWithSkills();
|
||||||
|
service.RecordCoinsFound(Coins.Of(10));
|
||||||
|
|
||||||
|
Assert.True(service.GrantSkill(BrickThrow));
|
||||||
|
Assert.Equal(SkillLevel.First, service.LevelOf(BrickThrow));
|
||||||
|
Assert.Equal(Coins.Of(10), service.TotalCoins);
|
||||||
|
|
||||||
|
Assert.False(service.GrantSkill(BrickThrow));
|
||||||
|
Assert.Equal(SkillLevel.First, service.LevelOf(BrickThrow));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_revoked_skill_is_gone_from_both_the_attempt_and_the_playthrough()
|
||||||
|
{
|
||||||
|
// BrickShieldSkillComponent consumes itself when broken, so this is gameplay rather than a cheat.
|
||||||
|
var campaign = TwoLevels();
|
||||||
|
var store = new InMemoryPlaythroughStore();
|
||||||
|
var service = new ProgressionService(
|
||||||
|
Profile.Empty(), store, new InMemoryProfileStore(), campaign, Skills(), Lives.Of(3));
|
||||||
|
service.StartPlaythrough(Id);
|
||||||
|
service.EnterLevel(First);
|
||||||
|
service.GrantSkill(BrickThrow);
|
||||||
|
service.FinishLevel(LevelTime.Of(10.0)); // commits it to the Playthrough
|
||||||
|
service.EnterLevel(Second);
|
||||||
|
|
||||||
|
service.RevokeSkill(BrickThrow);
|
||||||
|
|
||||||
|
Assert.False(service.IsOwned(BrickThrow));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Abandoning_an_attempt_discards_it_without_costing_a_life()
|
||||||
|
{
|
||||||
|
var service = ServiceWithSkills();
|
||||||
|
service.RecordCoinsFound(Coins.Of(30));
|
||||||
|
|
||||||
|
service.AbandonAttempt();
|
||||||
|
|
||||||
|
Assert.Equal(3, service.Lives.Value);
|
||||||
|
Assert.Equal(Coins.Zero, service.TotalCoins);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_cheat_rewrites_the_save_shaped_state_and_is_still_validated()
|
||||||
|
{
|
||||||
|
// Cheats go through the snapshot rather than bare mutators, so ADR-0005 holds: there is still
|
||||||
|
// no SetLives or AddCoins for gameplay code to reach for.
|
||||||
|
var service = ServiceWithSkills();
|
||||||
|
|
||||||
|
service.ApplyCheat(snapshot => snapshot with { Bank = 500, Lives = 9 });
|
||||||
|
|
||||||
|
Assert.Equal(Coins.Of(500), service.TotalCoins);
|
||||||
|
Assert.Equal(9, service.Lives.Value);
|
||||||
|
|
||||||
|
// Still validated on the way back in: a negative bank is refused, state left alone.
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(
|
||||||
|
() => service.ApplyCheat(snapshot => snapshot with { Bank = -1 }));
|
||||||
|
Assert.Equal(Coins.Of(500), service.TotalCoins);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Picking_up_a_coin_announces_the_new_total_so_the_hud_never_polls()
|
||||||
|
{
|
||||||
|
// CoinsChanged is emitted from four places today and subscribed by nobody, while Hud rebuilds
|
||||||
|
// three translated strings every frame in _Process. One announcement, one read path.
|
||||||
|
var service = ServiceWithSkills();
|
||||||
|
|
||||||
|
service.RecordCoinsFound(Coins.Of(7));
|
||||||
|
|
||||||
|
var announced = service.DrainEvents().OfType<TotalCoinsChanged>().Single();
|
||||||
|
Assert.Equal(Coins.Of(7), announced.Total);
|
||||||
|
Assert.Equal(Coins.Of(7), service.TotalCoins);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dying_announces_both_the_remaining_lives_and_the_coins_reverting_to_the_bank()
|
||||||
|
{
|
||||||
|
var service = ServiceWithSkills();
|
||||||
|
service.RecordCoinsFound(Coins.Of(40));
|
||||||
|
|
||||||
|
service.HandlePlayerDeath();
|
||||||
|
|
||||||
|
var events = service.DrainEvents();
|
||||||
|
Assert.Equal(2, events.OfType<LivesChanged>().Single().Remaining.Value);
|
||||||
|
// The attempt is discarded, so the 40 found coins were never banked.
|
||||||
|
Assert.Equal(Coins.Zero, events.OfType<TotalCoinsChanged>().Last().Total);
|
||||||
|
Assert.Equal(Coins.Zero, service.TotalCoins);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
progression.tests/SkillIdTests.cs
Normal file
20
progression.tests/SkillIdTests.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class SkillIdTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void A_blank_id_is_not_a_skill(string value)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => SkillId.Of(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ids_with_the_same_text_are_the_same_skill()
|
||||||
|
{
|
||||||
|
// Owned skills are held in a dictionary keyed by id, so value equality is what makes the
|
||||||
|
// type usable at all rather than a convenience.
|
||||||
|
Assert.Equal(SkillId.Of("brick_throw"), SkillId.Of("brick_throw"));
|
||||||
|
}
|
||||||
|
}
|
||||||
13
progression.tests/SkillLevelTests.cs
Normal file
13
progression.tests/SkillLevelTests.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression.Tests;
|
||||||
|
|
||||||
|
public class SkillLevelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void There_is_no_level_zero_because_unowned_is_absence()
|
||||||
|
{
|
||||||
|
// SkillData.Level defaults to 1 today, so "not owned" and "owned at level 1" are the same
|
||||||
|
// value and nothing can tell them apart. A Playthrough records only the skills it owns, so
|
||||||
|
// absence from that record is the only way to say "unowned" — level zero must not exist.
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => SkillLevel.Of(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
0
progression/.gdignore
Normal file
0
progression/.gdignore
Normal file
21
progression/AchievementId.cs
Normal file
21
progression/AchievementId.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable identity of an Achievement, matching the authored <c>AchievementResource</c> id that Steam
|
||||||
|
/// also keys on.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct AchievementId : IEquatable<AchievementId>
|
||||||
|
{
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
private AchievementId(string value) => Value = value;
|
||||||
|
|
||||||
|
public static AchievementId Of(string value) => string.IsNullOrWhiteSpace(value)
|
||||||
|
? throw new ArgumentException("An achievement id cannot be blank.", nameof(value))
|
||||||
|
: new AchievementId(value);
|
||||||
|
|
||||||
|
public bool Equals(AchievementId other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||||||
|
public override bool Equals(object? obj) => obj is AchievementId other && Equals(other);
|
||||||
|
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
|
||||||
|
public override string ToString() => Value;
|
||||||
|
}
|
||||||
75
progression/Attempt.cs
Normal file
75
progression/Attempt.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One try at a Level: a ledger of everything that happened during the try, plus how it ended.
|
||||||
|
/// Never persisted — a discarded Attempt is one that is simply thrown away.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Attempt
|
||||||
|
{
|
||||||
|
public Attempt(LevelId level) => Level = level;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which Level this is an attempt at. Settlement reads this rather than trusting a level
|
||||||
|
/// supplied by the caller, so finishing a Replay cannot advance campaign position.
|
||||||
|
/// </summary>
|
||||||
|
public LevelId Level { get; }
|
||||||
|
|
||||||
|
public AttemptOutcome Outcome { get; private set; } = AttemptOutcome.InProgress;
|
||||||
|
|
||||||
|
public Coins CoinsFound { get; private set; } = Coins.Zero;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records coins picked up during this Attempt. Silently ignored once the Attempt has ended:
|
||||||
|
/// a pickup firing after death is a real event, not an error, and it does not count.
|
||||||
|
/// </summary>
|
||||||
|
public void RecordCoinsFound(Coins amount)
|
||||||
|
{
|
||||||
|
if (Outcome != AttemptOutcome.InProgress) return;
|
||||||
|
|
||||||
|
CoinsFound = CoinsFound.Add(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Coins CoinsSpent { get; private set; } = Coins.Zero;
|
||||||
|
|
||||||
|
private readonly Dictionary<SkillId, SkillLevel> _skillsBought = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a Skill bought or upgraded during this Attempt, at the level it ended up at.
|
||||||
|
/// Returns false once the Attempt has ended.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Affordability is not checked here — it spans the Attempt and the Playthrough's Bank, so it
|
||||||
|
/// belongs to the caller that can see both. This records what was agreed, not whether it was
|
||||||
|
/// allowed.
|
||||||
|
/// </remarks>
|
||||||
|
public bool TryRecordPurchase(SkillId skill, SkillLevel resulting, Coins cost)
|
||||||
|
{
|
||||||
|
if (Outcome != AttemptOutcome.InProgress) return false;
|
||||||
|
|
||||||
|
_skillsBought[skill] = resulting;
|
||||||
|
CoinsSpent = CoinsSpent.Add(cost);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<SkillId, SkillLevel> SkillsBought => _skillsBought;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends this Attempt in death. Returns false if it has already ended, in which case nothing
|
||||||
|
/// changes — a second death event costs no further lives.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryEndInDeath() => TryEnd(AttemptOutcome.Died);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends this Attempt in completion. Returns false if it has already ended, in which case
|
||||||
|
/// nothing changes — a second completion event banks nothing further.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryEndInCompletion() => TryEnd(AttemptOutcome.Cleared);
|
||||||
|
|
||||||
|
private bool TryEnd(AttemptOutcome outcome)
|
||||||
|
{
|
||||||
|
if (Outcome != AttemptOutcome.InProgress) return false;
|
||||||
|
|
||||||
|
Outcome = outcome;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
progression/AttemptOutcome.cs
Normal file
12
progression/AttemptOutcome.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How an Attempt finished, or that it has not finished yet. An Attempt reaches a terminal outcome
|
||||||
|
/// once and refuses to change afterwards — that refusal is what guarantees one death costs one life.
|
||||||
|
/// </summary>
|
||||||
|
public enum AttemptOutcome
|
||||||
|
{
|
||||||
|
InProgress,
|
||||||
|
Died,
|
||||||
|
Cleared,
|
||||||
|
}
|
||||||
37
progression/Campaign.cs
Normal file
37
progression/Campaign.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The authored ordering of Levels that defines progression. Shared by every Playthrough — it is
|
||||||
|
/// authored data, not per-Playthrough state, and carries no scene or presentation detail.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Campaign
|
||||||
|
{
|
||||||
|
private readonly List<LevelId> _levels;
|
||||||
|
|
||||||
|
private Campaign(List<LevelId> levels) => _levels = levels;
|
||||||
|
|
||||||
|
public static Campaign Of(IEnumerable<LevelId> levels)
|
||||||
|
{
|
||||||
|
var ordered = levels.ToList();
|
||||||
|
|
||||||
|
if (ordered.Count == 0)
|
||||||
|
throw new ArgumentException("A campaign needs at least one level.", nameof(levels));
|
||||||
|
|
||||||
|
if (ordered.Distinct().Count() != ordered.Count)
|
||||||
|
throw new ArgumentException("A campaign cannot list the same level twice.", nameof(levels));
|
||||||
|
|
||||||
|
return new Campaign(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LevelId First => _levels[0];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Level after <paramref name="level"/>, or null if it is the last one or not in this
|
||||||
|
/// Campaign at all.
|
||||||
|
/// </summary>
|
||||||
|
public LevelId? Next(LevelId level)
|
||||||
|
{
|
||||||
|
var at = _levels.IndexOf(level);
|
||||||
|
return at >= 0 && at + 1 < _levels.Count ? _levels[at + 1] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
progression/Coins.cs
Normal file
43
progression/Coins.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A non-negative quantity of coins. Amounts only — there is no signed coin value; a shortfall is
|
||||||
|
/// a failed subtraction the caller must handle, never a negative total.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct Coins : IEquatable<Coins>
|
||||||
|
{
|
||||||
|
public int Value { get; }
|
||||||
|
|
||||||
|
private Coins(int value) => Value = value;
|
||||||
|
|
||||||
|
public static readonly Coins Zero = new(0);
|
||||||
|
|
||||||
|
public static Coins Of(int value) => value < 0
|
||||||
|
? throw new ArgumentOutOfRangeException(nameof(value), value, "Coins cannot be negative.")
|
||||||
|
: new Coins(value);
|
||||||
|
|
||||||
|
public Coins Add(Coins other) => new(Value + other.Value);
|
||||||
|
|
||||||
|
public bool IsAtLeast(Coins other) => Value >= other.Value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtracts <paramref name="amount"/>. Returns false when the amount exceeds what is held,
|
||||||
|
/// leaving <paramref name="remaining"/> untouched rather than clamping to zero.
|
||||||
|
/// </summary>
|
||||||
|
public bool TrySubtract(Coins amount, out Coins remaining)
|
||||||
|
{
|
||||||
|
if (amount.Value > Value)
|
||||||
|
{
|
||||||
|
remaining = this;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = new Coins(Value - amount.Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(Coins other) => Value == other.Value;
|
||||||
|
public override bool Equals(object? obj) => obj is Coins other && Equals(other);
|
||||||
|
public override int GetHashCode() => Value;
|
||||||
|
public override string ToString() => Value.ToString();
|
||||||
|
}
|
||||||
19
progression/EventLog.cs
Normal file
19
progression/EventLog.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collects the events an aggregate raises until someone drains them. Composed rather than
|
||||||
|
/// inherited — aggregates own one, they are not a kind of it.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class EventLog
|
||||||
|
{
|
||||||
|
private readonly List<ProgressionEvent> _events = new();
|
||||||
|
|
||||||
|
public void Raise(ProgressionEvent @event) => _events.Add(@event);
|
||||||
|
|
||||||
|
public IReadOnlyList<ProgressionEvent> Drain()
|
||||||
|
{
|
||||||
|
var drained = _events.ToList();
|
||||||
|
_events.Clear();
|
||||||
|
return drained;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
progression/IPlaythroughStore.cs
Normal file
12
progression/IPlaythroughStore.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Mr.BrickAdventures.Progression;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where Playthroughs are kept. One per slot; the JSON adapter writes one file each, so saving a run
|
||||||
|
/// never rewrites the Profile and deleting a slot is deleting a file.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPlaythroughStore
|
||||||
|
{
|
||||||
|
void Save(Playthrough playthrough);
|
||||||
|
|
||||||
|
LoadResult<Playthrough> Load(PlaythroughId id);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user