21 Commits

Author SHA1 Message Date
4e6d5cf6f9 no idea what is it 2026-11-05 19:19:37 +01:00
e8d1da8f89 feat: lives and death move to the new domain (commit C of the swap)
fixes the regression commit B introduced: coins and skills lived in the
Attempt ledger, but nothing discarded it, because LivesStateHandler still
called the old store and HandlePlayerDeath was never invoked. Dying kept
everything earned that level. Discard-on-death is the ledger's purpose,
so it could not be separated from it.

new AttemptFailed(remaining, runOver) event, raised after the life is
spent and the ledger discarded. DeathScreen and GameOverScreen react to
that instead of being wired to HealthComponent.Death by scene connections
which fired *before* the life was charged - on the last life game-over
saw one life left and showed nothing, then the death screen saw zero and
refused to reload. 18 scene connections deleted; the race is now
unrepresentable rather than fixed.

DeathScreen also had two read paths for lives - a signal and a direct
store read - because neither was reliable alone. Now one.

Hud reads both facts from signals; only health still polls, and that
belongs to the component rather than to progression. GameManager no
longer touches GameStateStore or SaveSystem at all.
2026-07-28 20:19:35 +02:00
360f73ba24 feat: coins and skills move to the new domain (commit B of the swap)
atomic pair - purchasing couples them, so neither moves alone.

SkillData.Level is gone. It was an [Export] on a cached Resource: one
instance per process, so the level never persisted, leaked between save
slots, and made "unowned" indistinguishable from "level 1". Level now
comes from the Playthrough and is passed to ISkill.Initialize.

SkillManager reconciles from SkillSetChanged instead of being told by
each caller. The old shape was "record it, then tell SkillManager" as two
calls at four sites; AddSkill's early return when no player is registered
left the skill owned but inert. Reconciliation also tracks the level each
component was built at, so an upgrade rebuilds it rather than being
patched in from outside.

SkillUnlockerComponent collapses to one TryBuy - unlock and upgrade were
two methods each doing affordability, debit, level mutation and manager
notification in order.

The guard three components copy-pasted around Upgrades[Level - 1] - and
which BrickArmor omitted - now lives once in SkillComponentBase.

Hud reads coins from CoinsChanged instead of rebuilding translated
strings every frame. Lives still polls; it moves with the death path.
2026-07-28 20:13:24 +02:00
b6a8448743 feat(progression): capabilities the adapters need (commit A of the swap)
no behaviour change - additive only.

GrantSkill: pickups are free unlocks, and collecting one already owned is
a no-op rather than a free upgrade.
RevokeSkill: for skills that consume themselves (brick shield).
AbandonAttempt: leaving a level without dying, so no life is charged.

ApplyCheat rewrites the Playthrough through its snapshot rather than via
SetLives/AddCoins mutators, which ADR-0005 rules out - a bare mutator is
an invitation, and the old code had two paths to exactly that. Every
value is re-validated on the way back in and a rejected cheat leaves the
Playthrough untouched, which is asserted.

73 tests.
2026-07-28 20:07:47 +02:00
f4411e05a3 fix: SpeedRunHud never detached from the autoload's signal
every level load left another handler attached to a freed HUD.
SpeedRunManager outlives the scene, so it kept calling them and each one
wrote to a disposed Label - thousands of ObjectDisposedException per
frame, growing with each load.

Latent all along: StartTimer was only ever called from StartNewGame, so
after a Continue the timer never ticked and the dead handlers never ran.
Calling StartTimer on Continue (needed for splits and best times) made
it visible.

Also stop the timer when leaving to the menu - StopTimer existed and had
no callers, so the run kept ticking in the main menu.
2026-07-28 20:00:55 +02:00
a1c9042652 feat: level progression moves to the new domain (slice 10b, step 1)
narrowest safe step: only level identity and progression move. Coins,
lives, skills and death stay entirely on GameStateStore and behave as
before, so nothing is tracked in two places.

GameManager addresses levels by stable LevelId via the catalogue;
LevelScenes is gone, so reordering levels no longer invalidates saves.
Every level entry goes through EnterLevel, which fixes LevelStarted never
firing on the first level or after Continue - ghost recording and splits
have been dead outside mid-campaign.

GoTo announces after a frame, once the incoming scene is actually
current. ChangeSceneToPacked is deferred, so the old code handed
GhostEventHandler the scene being torn down.

FinishLevel takes LevelTime? - GetCurrentLevelTime can legitimately be 0
and inventing a value is exactly the bad data LevelTime refuses.

Continue distinguishes absent from corrupt and refuses to overwrite a
corrupt save. Statistics move to the Profile. 69 tests.
2026-07-28 19:57:01 +02:00
ca54bf0f82 feat(progression): read surface and change announcements (slice 10a)
reads for the UI: TotalCoins, Lives, IsRunOver, CurrentLevel,
LevelOf/IsOwned/CanAfford, HasCleared. The read half of candidate 01 -
callers get a narrow surface instead of reaching into state.

new domain events LivesChanged / TotalCoinsChanged / SkillSetChanged,
announced by comparing against the last announced value, so an operation
can neither forget to announce nor announce something unchanged.

HandlePlayerDeath now clears the spent attempt - it was still counting
towards TotalCoins after its ledger had been discarded.

republisher and EventBus extended; SkillSetChanged is what SkillManager
will reconcile against instead of the non-atomic call pairs. 65 tests.
2026-07-28 19:50:05 +02:00
c1c3e7b020 chore: editor artifacts - catalogue typed array, script uids, compiled translations 2026-07-28 19:46:30 +02:00
627900b2e8 feat(progression): composition root and event republisher (slice 9)
ProgressionRoot autoload builds the JSON stores from
GlobalizePath("user://"), loads the Profile, reads the authored upgrade
tables into a SkillCatalogue, and owns the ProgressionService. Every
mutating operation is wrapped so draining and republishing cannot be
forgotten - the one risk of the DrainEvents design, now confined to one
file.

ProgressionEventRepublisher turns domain records into EventBus signals
(ADR-0004), so existing subscribers and scene wiring keep working. New
RunEnded signal: GameOverScreen reads Lives directly today, which is the
last-life soft-lock.

Registered after SkillManager, since the skill catalogue is built from
AvailableSkills. GameStateStore stays until slice 10 moves the callers.

Transitional: LevelStarted is republished with a catalogue index because
GhostManager names files ghost_level_{index}.json.
2026-07-28 19:43:04 +02:00
f85c16c641 fix(levels): ScenePath must be a string, not a PackedScene
slice 7 regression. level_village_2..5.tscn each reference their own
LevelResource for the death screen label, so pointing the resource back
at the scene forms a cycle Godot cannot resolve - "Parse Error: Busy"
and the level fails to load entirely.

ScenePath was a string for exactly this reason. Reverted, and the rot it
allowed (forest_1 aimed at a nonexistent scene) is now caught by
LevelCatalogue.Validate checking every path resolves, plus a repo-level
structural check - fixed by validation rather than by typing.

project.godot: restores the C# feature flag and dialogue_manager
uses_dotnet, which `godot --headless --import` strips on every run.
2026-07-28 19:40:02 +02:00
7a69292066 feat(progression): json stores on a real filesystem (slice 8)
new progression.adapters project, no engine reference. Stores take a
directory path; Godot resolves user:// with GlobalizePath and passes it
in, which is what makes them testable at all - originally I'd written
this slice off as untestable.

version lives in a file envelope, not the snapshot, so the domain only
knows the current shape. A newer-build save reports Corrupt with a
reason instead of masquerading as "no save" and getting overwritten.

atomic write via temp + rename. One file per aggregate: saving a run
leaves the profile file byte-identical, asserted.

guard rail fixed - it was matching prose in doc comments, now anchored
to using-directives plus a project.assets.json check. 63 tests.
2026-07-28 19:35:43 +02:00
f991bb99fc feat(levels): level catalogue with stable ids (slice 7)
LevelResource gains a stable Id and swaps the string ScenePath for an
editor-tracked PackedScene. The old path field was never read and had
rotted - forest_1 pointed at level_forest_5.tscn, which doesn't exist,
and borrowed LEVEL_5_NAME so it would have displayed "VILLAGE - 5".

LevelCatalogue lists levels in campaign order and produces the domain's
Campaign; an authoring mistake fails at boot because Campaign.Of rejects
blank and duplicate ids. Not a domain port - nothing in the domain
resolves a level to a scene.

All 8 scenes now have a resource; campaign keeps the existing 6
(tutorial_1, village_1..5). village_6 and forest_1 are catalogued but
deliberately out of the campaign.

GameManager.LevelScenes stays until slice 10 rewrites GameManager.
2026-07-28 19:28:56 +02:00
330e10880c feat(progression): use cases (slice 6)
ProgressionService holds the loaded Profile, active Playthrough and
active Attempt, so "exactly one active Attempt" has a testable home.

EnterLevel always announces LevelStarted - the reason ghost recording
and splits never worked on level 1 or after a Continue. Refuses
unreachable levels. Does not touch campaign position, so a Replay
leaves it alone.

SkillCatalogue returns null for a skill with no authored upgrades,
killing the Upgrades[0].Cost overrun. PurchaseSkill reads the current
level from the ledger first, so one Attempt can unlock then upgrade.
HandlePlayerDeath now persists - death was free if you quit.

SkillLevel.First/Next driven out here by a real caller. 53 tests.
2026-07-28 19:10:06 +02:00
003ec4b010 feat(progression): snapshots, ports, in-memory adapters (slice 5)
flat snapshot record per aggregate - separate files mean saving a run
doesn't rewrite the Profile, deleting a slot is deleting a file, and a
corrupt slot doesn't cost achievements or best times.

snapshots hold primitives so Restore re-runs the value-object factories;
a serialiser building LevelId directly would bypass every guard.

LoadResult has three outcomes: Absent != Corrupt. Collapsing them is the
bug where a version bump made ContinueGame overwrite the save.

Profile.Restore writes collections directly - replaying UnlockAchievement
would re-fire popups and Steam pushes on every load. 44 tests.
2026-07-28 19:02:41 +02:00
05149bb4a9 feat(progression): Profile aggregate (slice 4)
best times only improve; achievement unlock announces on the transition
only, so popup/Steam subscribers cannot double-fire; statistics are
increase-only (IncrementStat took any int).

RelockAchievement is console-only and documented as not reaching Steam -
a Steam achievement cannot be un-earned, so the disagreement is
permanent and deliberate.

refactor: extract EventLog, composed by both aggregates now that the
duplication actually exists. 34 tests.
2026-07-28 18:56:58 +02:00
ffa2b986ae feat(progression): Playthrough aggregate (slice 3)
no LoseLife (ADR-0005). SettleDeath / SettleCompletion defer to the
Attempt's terminal outcome, so both are atomic and idempotent.
SettleCompletion branches on HasCleared itself - a Replay cannot bank
or move campaign position backwards (ADR-0003).

Campaign value object holds authored level order; ReachableLevels is
derived, replacing the stored UnlockedLevels. Coins.IsAtLeast driven
out by cross-scope affordability.

29 tests. CONTEXT.md: Campaign, Settle.
2026-07-28 18:52:20 +02:00
40bd5c1d86 feat(progression): Attempt ledger with terminal-outcome guard (slice 2)
Attempt carries its LevelId so settlement decides First Clear vs Replay
without trusting the caller. Outcome is terminal and refuses to change:
one death costs one life, one completion banks once.

Coins.Zero/Add driven out by the ledger rather than added up front.
21 tests. ADR-0005: Playthrough settles Attempts, has no LoseLife.
2026-07-28 18:40:20 +02:00
9767941c65 feat(progression): remaining slice 1 value objects
Lives (floors at zero, exhaustion observable), LevelId, SkillId,
PlaythroughId (never self-generates, keeps tests deterministic),
StatName enum, LevelTime rejects NaN/infinity.

14 tests. Arithmetic/comparison members deferred to the slices whose
behaviour needs them rather than added speculatively.
2026-07-28 18:27:26 +02:00
313a5315b1 feat(progression): Coins, LevelTime, SkillLevel core invariants (slice 1)
three bug-encoding invariants, one red/green cycle each:
- Coins.TrySubtract fails instead of clamping
- LevelTime rejects zero (continued-run zero best-times)
- SkillLevel has no zero (unowned is absence, not Level=1)

plan: drop signed NetCoins; add Attempt outcome as idempotency guard
2026-07-28 18:22:46 +02:00
c3bbf3282a chore: scaffold progression domain + test projects (slice 0)
net8.0 classlib, no engine ref, EnableDynamicLoading; xunit test project
referencing domain only; excluded from Godot's **/*.cs glob; .gdignore both
2026-07-28 18:14:24 +02:00
dd113af786 docs: progression CONTEXT, ADRs 0001-0004, phase A plan
ignore .idea/ .superpowers/ bin/ obj/; drop 3 identical csproj backups
2026-07-28 18:11:01 +02:00
222 changed files with 4846 additions and 407 deletions

8
.gitignore vendored
View File

@@ -3,3 +3,11 @@
/android/ /android/
builds/ builds/
.worktrees/ .worktrees/
# IDE / local tooling
.idea/
.superpowers/
# .NET build output
bin/
obj/

View File

@@ -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);

View File

@@ -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&lt;PackedScene&gt; 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()

View 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;
}
}

View File

@@ -0,0 +1 @@
uid://cjm0g50la7uie

View 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
}

View File

@@ -0,0 +1 @@
uid://b6amqp58yjfc4

View File

@@ -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
View 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.

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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"]

View 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

View File

View 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}");
}
}
}

View 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);
}

View 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);
}

View File

@@ -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>

View 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);

View File

View 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);
}
}

View 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));
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}

View 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);
}
}

View 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));
}
}

View 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));
}
}

View 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);
}
}

View File

@@ -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>

View 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));
}
}

View 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));
}
}

View 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);
}
}

View 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));
}
}

View 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));
}
}

View 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);
}
}

View 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>());
}
}

View 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);
}
}

View 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"));
}
}

View 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
View File

View 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
View 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;
}
}

View 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
View 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
View 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
View 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;
}
}

View 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