docs: progression CONTEXT, ADRs 0001-0004, phase A plan
ignore .idea/ .superpowers/ bin/ obj/; drop 3 identical csproj backups
This commit is contained in:
15
docs/adr/0001-three-progression-scopes.md
Normal file
15
docs/adr/0001-three-progression-scopes.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Player progression has three scopes, not two
|
||||
|
||||
Progression state was two scopes — `PlayerState` (persisted) and `SessionState` (transient) — with achievements
|
||||
and best times leaking out as special cases: `PlayerState.Reset()` deliberately skipped `UnlockedAchievements`,
|
||||
and best times lived in the ghost replay files rather than the save. We split progression into **Attempt**,
|
||||
**Playthrough** and **Profile** (defined in `CONTEXT.md`), because introducing save slots makes the third scope
|
||||
mandatory rather than a preference: Steam achievements are per-account and cannot be un-earned, so a
|
||||
per-Playthrough achievement list would permanently contradict Steam the moment a second slot exists. Best times
|
||||
follow the same argument — a completionist's record is not something starting a new game should reset.
|
||||
|
||||
## Considered options
|
||||
|
||||
Keeping one persisted root with a hand-maintained list of fields that survive New Game. Rejected: that is exactly
|
||||
what the old `Reset()` did, and it had already drifted — achievements survived by omission rather than by design,
|
||||
and nothing would have caught the next missed field.
|
||||
16
docs/adr/0002-attempt-is-a-ledger.md
Normal file
16
docs/adr/0002-attempt-is-a-ledger.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Attempt is a separate aggregate holding a ledger
|
||||
|
||||
**Playthrough** and **Attempt** are separate aggregate roots rather than one root containing the other, and an
|
||||
Attempt records a *ledger* — coins found, coins spent, skills bought — rather than just a purse of coins found.
|
||||
Separate roots keeps each aggregate small and gives the Attempt its own lifecycle, since it is never persisted.
|
||||
The ledger exists so that the rule "nothing survives an Attempt unless you complete the level" applies to
|
||||
purchases too: under the previous model `RemoveCoins` drained session coins and then debited banked coins
|
||||
immediately, so buying a skill mid-level and then dying lost both the coins and the skill.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Affordability is a cross-aggregate **read** (`attempt.CoinsFound + playthrough.Bank >= cost`). Reads may cross
|
||||
scopes; writes may not.
|
||||
- Purchasing writes only the Attempt. The only operation touching both roots is Commit, which has exactly one
|
||||
call site.
|
||||
- Discarding an Attempt undoes purchases rather than refunding them — under the ledger they never happened.
|
||||
15
docs/adr/0003-only-first-clear-commits.md
Normal file
15
docs/adr/0003-only-first-clear-commits.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Only a First Clear commits an Attempt
|
||||
|
||||
The game is moving from a purely sequential campaign to one where completionists can select and replay levels
|
||||
they have already beaten. Coins and skills bank only on a **First Clear** — the first completion of a Level
|
||||
within a Playthrough — while a **Replay** records Best Time, achievements and statistics to the Profile and
|
||||
discards the Attempt. We chose this over committing on every completion because grinding the easiest level would
|
||||
otherwise be the optimal way to buy every skill.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A Replay must never advance campaign position. Sharing one code path between First Clear and Replay without an
|
||||
explicit branch is the obvious way to reintroduce this bug.
|
||||
- `ClearedLevels` answers "is this a First Clear?" for free, since it already deduplicates.
|
||||
- Coins found during a Replay are lost on completion, not just on death. This is intentional and worth surfacing
|
||||
in the UI.
|
||||
21
docs/adr/0004-domain-does-not-reference-godot.md
Normal file
21
docs/adr/0004-domain-does-not-reference-godot.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# The progression domain does not reference Godot
|
||||
|
||||
The progression domain lives in its own assembly with no Godot reference. Aggregates raise plain C# event records
|
||||
as part of their operations, and a single adapter republishes those as `EventBus` signals. This is the only
|
||||
enforceable version of the goal — correctness checkable without a scene tree, with the compiler enforcing the rule
|
||||
rather than discipline. The leak it removes was already visible: `PlayerState` documented itself as a POCO "for
|
||||
predictable state management" while holding `List<SkillData>`, and `SkillData : Resource`, which is why saves had
|
||||
to store skill *names* and re-resolve them through `SkillManager` on load.
|
||||
|
||||
Republishing rather than replacing `EventBus` keeps existing scene wiring and subscribers intact, and makes
|
||||
"state changed ⇒ signal fired" un-bypassable — the original defect, where `ConsoleManager` mutated state directly
|
||||
and re-emitted signals by hand, sometimes without the invariants the store's own operations applied.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `GameStateStore` stops being a `Node`. The autoload becomes a composition root that owns the aggregates, so the
|
||||
aggregates' lifetime is no longer tied to the scene tree and most `Store?.` null-guards disappear.
|
||||
- Skill progression cannot live on `SkillData`. A `Resource` is one cached instance per process, so with save
|
||||
slots it would leak levels between Playthroughs.
|
||||
- Godot types (`Vector2`, `Mathf`, `GD.Print`) are not available in the domain; where they appear today they are
|
||||
replaced by domain values or moved to adapters.
|
||||
Reference in New Issue
Block a user