docs: progression CONTEXT, ADRs 0001-0004, phase A plan

ignore .idea/ .superpowers/ bin/ obj/; drop 3 identical csproj backups
This commit is contained in:
2026-07-28 18:11:01 +02:00
parent 6b784303b4
commit dd113af786
7 changed files with 427 additions and 0 deletions

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.