geometry-tower-defense/design/gdd/progression.md

477 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Progression (成长系统)
> **Status**: Revised — blocking fixes applied (runStats/LifetimeStats/UnlockResult schemas added; Fantasy gold-cap contradiction resolved; dependency statuses updated; Green pool always-available note added; getter ACs added; MaxPlayerGold owner reference added)
> **Author**: SepComet + agents
> **Last Updated**: 2026-04-30
> **Implements Pillar**: Collection and mastery — permanent unlocks and lifetime statistics that grow across runs
## Overview
Progression is the **permanent state manager** that persists across runs. It owns the read/write of unlock state (themes, difficulty tiers, component pools) and the lifetime statistics record. It does not hold run-level state (Gold, Inventory, Tower configs — those live and die within a run). On every `RunEnd`, the Node System sends final run stats to Progression; Progression evaluates whether any unlock thresholds are crossed; if so, the player's unlock pool expands. The player sees this as "my account is more powerful" — new options available at the start of every subsequent run.
## Player Fantasy
**"Complete your collection. Fill every gap. Nothing left on the table."**
The Progression fantasy is the feeling of **relentless collection** — every run earns something toward a permanent expansion of what's possible. A component type unlocked, a difficulty tier cracked, a theme revealed. The next run the player opens, they notice the new option immediately and feel the game acknowledge their effort. The goal is to empty the unlock list — to have seen everything the game offers. This is the roguelike's fundamental pull: *the set of available tools today is larger than it was ten runs ago*.
**Note on Gold**: Gold earned during a run is spent in-shop and does not persist between runs. However, the gold cap (`MaxPlayerGold = 9999`, defined in `shop.md`) means excess gold earned within a run is discarded — players cannot bank unlimited gold across runs. The Progression fantasy is about unlocks and statistics, not about accumulating currency.
The player should feel:
- **Driven by gaps** — the unlock list shows what's missing; completing a set feels like closing a circuit
- **Rewarded for exploration** — trying a new difficulty or theme unlocks more content as a side effect
- **Invested in permanence** — nothing earned is ever lost; the account is a record of everything accomplished
## Detailed Design
### Core Rules
**SR1. Data Persistence**: `ProgressionData` is a persistent save-file object. It is loaded at game start and saved after every `RecordRunEnd()` call. It holds: `UnlockedDifficulties`, `UnlockedThemes`, `UnlockedComponentPools`, `UnlockedStartingLoadouts`, and `LifetimeStats`. (Note: `CompletionCounts` is not a separate field — per-difficulty and per-theme win counts are stored within `LifetimeStats.winsByDifficulty` and `LifetimeStats.winsByTheme`.) Gold is NOT persisted between runs; the run's earned gold is spent in-shop or discarded if it exceeds `MaxPlayerGold` (9999, per `shop.md`).
**SR2. Unlock Evaluation Trigger**: On every `RunEnd` with `bossDefeated = true`, the Node System calls `Progression.RecordRunEnd(runStats)`. Progression evaluates all unlock conditions against the current `ProgressionData`. Unlocked items are added to the appropriate pool immediately and an `UnlockResult[]` is returned. Loss runs record stats only; no unlock evaluation.
**SR3. Difficulty Unlock Chain**: `Normal` is always unlocked. `Hard` unlocks when player defeats Boss on Normal. `Expert` unlocks when player defeats Boss on Hard. `Nightmare` unlocks when player defeats Boss on Expert.
**SR4. Theme Unlock**: Themes are parallel — any theme whose unlock condition is met becomes available. Player selects theme at run start from the New Game screen. Unlock condition per theme stored in `DRTheme.UnlockCondition`.
**SR5. Component Pool Unlock**: Component pools (rarity tiers) unlock based on difficulty and win-count conditions. **White components are always available.** Green and above are gated by unlock evaluation. Pools are additive — when a pool unlocks, its components become available in shop and drop tables. `DRComponentPool.UnlockCondition` defines the gating condition per pool.
**SR6. Starting Loadout Selection**: At run start, player chooses one loadout from all `UnlockedStartingLoadouts`. The UI defaults to the first available loadout if no preference is set. Starting bonuses are applied immediately when the run begins: gold added to `PlayerInventoryComponent.Gold`, pre-built towers assembled and placed in roster.
**SR7. Lifetime Stats**: `LifetimeStats` is updated on every run end (win or loss): `TotalRunsStarted++`, `TotalGoldEarned += gold`, `FurthestNodeReached = max(previous, nodesCompleted)`, etc. Win stats updated only on `bossDefeated = true`.
**SR8. Unlock Feedback**: On successful unlock evaluation, a `UnlockedEventArgs` is fired. UI listens and shows an animated toast popup listing the newly unlocked item(s). The toast appears on the RunEnd victory screen before returning to menu.
### Data Schemas
**`RunStats`** — passed to `RecordRunEnd()` by the Node System on every run end:
| Field | Type | Range | Description |
|-------|------|-------|-------------|
| `runId` | string | non-empty | Unique identifier for this run (for deduplication) |
| `difficulty` | DifficultyType | Normal..Nightmare | Difficulty at run start |
| `theme` | ThemeType | per-theme enum | Theme selected at run start |
| `goldEarned` | int | ≥ 0 | Total gold earned during this run (from combat rewards) |
| `nodesCompleted` | int | 010 | Nodes cleared this run (09 non-boss + optional Boss at 10) |
| `bossDefeated` | bool | {true, false} | Whether Boss was defeated |
| `coinsEarned` | int | ≥ 0 | Combat-internal Coin accumulated this run (not persisted) |
| `componentsCollectedThisRun` | int | ≥ 0 | Components picked up or purchased this run |
| `enemiesDefeatedThisRun` | int | ≥ 0 | Enemies killed this run |
**`LifetimeStats`** — persisted in `ProgressionData`:
| Field | Type | Range | Description |
|-------|------|-------|-------------|
| `totalRunsStarted` | int | ≥ 0 | Total runs started (win and loss) |
| `totalGoldEarned` | long | ≥ 0 | Cumulative gold earned across all runs |
| `furthestNodeReached` | int | 010 | Highest node index reached in any run |
| `totalNodesCompleted` | int | ≥ 0 | Total nodes cleared across all runs |
| `totalEnemiesDefeated` | long | ≥ 0 | Cumulative enemies killed |
| `totalComponentsCollected` | long | ≥ 0 | Cumulative components acquired |
| `totalWins` | int | ≥ 0 | Total winning runs (bossDefeated = true) |
| `winsByDifficulty` | `Dictionary<DifficultyType, int>` | ≥ 0 per entry | Wins broken down by difficulty |
| `winsByTheme` | `Dictionary<ThemeType, int>` | ≥ 0 per entry | Wins broken down by theme |
| `bossesDefeated` | int | ≥ 0 | Total Boss defeats (may differ from `totalWins` if Boss defeat conditions change) |
**`UnlockResult`** — returned by `UnlockEvaluation()`:
| Field | Type | Description |
|-------|------|-------------|
| `unlockedType` | `UnlockType` enum | Category: `Difficulty`, `Theme`, `ComponentPool`, `StartingLoadout` |
| `unlockedId` | string | Identifier of the unlocked item (e.g., difficulty name, theme ID) |
| `unlockedName` | string | Display name shown in the Toast popup |
**`UnlockedEventArgs`** — fired on successful unlocks:
| Field | Type | Description |
|-------|------|-------------|
| `unlocks` | `UnlockResult[]` | All items unlocked by this run's evaluation |
### States and Transitions
Progression is **purely passive** — it has no runtime state machine. It exposes interfaces that other systems call.
| State | Description |
|-------|-------------|
| `Loaded` | Save file loaded into memory. Progression data is current. |
| `Evaluating` | `RecordRunEnd()` is executing; unlock conditions are being checked. |
| `Dirty` | New unlocks found; waiting for save. |
| `Saved` | Dirty state persisted to disk. |
*No user-facing state machine — UI screens that display Progression data (Profile, New Game) are owned by the UI layer, not by Progression.*
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|------------|
| **Node System** | Receives from | `RecordRunEnd(runStats)` — called on every run end (win or loss). `runStats` contains `{goldEarned, nodesCompleted, bossDefeated, coinsEarned, componentsDropped}`. |
| **Shop System** | Reads from | `GetUnlockedComponentPools()` — shop uses this to determine which component rarities appear in `BuildShopGoods()`. |
| **UI / New Game Screen** | Reads from | `GetUnlockedDifficulties()`, `GetUnlockedThemes()`, `GetUnlockedStartingLoadouts()` — populate run setup UI. |
| **UI / Profile Screen** | Reads from | `GetLifetimeStats()` — displays career statistics. |
| **UI / Run End Screen** | Receives from | `OnUnlockedEventArgs` — triggers toast popup for new unlocks. |
| **Event System** | Soft read | Event outcomes do not directly affect Progression. Events may reference `GetLifetimeStats()` for conditional text. |
## Formulas
### 1. UnlockEvaluation — Run-End Unlock Check
The `UnlockEvaluation(runStats)` function is called by `Progression.RecordRunEnd()` on every winning run. It checks all locked unlockables and returns a list of `UnlockResult` objects for newly unlocked items.
**Function signature:**
```
UnlockResult[] UnlockEvaluation(RunStats runStats)
```
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| bossDefeated | b | bool | {true, false} | Whether Boss was defeated this run |
| difficulty | d | DifficultyType | Normal..Nightmare | Difficulty at run start |
| totalWins | w | int | ≥ 0 | Cumulative wins across all runs |
| totalEnemiesDefeated | e | int | ≥ 0 | Cumulative enemies killed (lifetime) |
| nodesCompleted | n | int | 010 | Nodes cleared this run (09 pre-boss, 10 if Boss was reached). Per the Node System GDD (Approved), a run has 10 nodes total; Event and Shop nodes contribute 0 gold but still count toward `nodesCompleted` for stat tracking. |
**Output Range:** 0 to N newly unlocked items per run. In practice, typically 02.
---
**A. Difficulty Unlock (chain)**
```
nextDifficulty(d) = {
Normal → Hard,
Hard → Expert,
Expert → Nightmare,
Nightmare → null (no next tier)
}
IF b == true AND nextDifficulty(d) != null THEN
unlock(nextDifficulty(d))
```
**Example:** Player defeats Boss on Normal → `nextDifficulty(Normal) = Hard` → Hard is unlocked.
---
**B. Theme Unlock (per-theme condition from DRTheme)**
```
FOR each locked theme T:
IF T.condition.type == WinOnDifficulty
AND b == true AND d >= T.condition.targetDifficulty
OR T.condition.type == TotalWins
AND w >= T.condition.targetWins
OR T.condition.type == Mixed
AND b == true AND d >= T.condition.minDifficulty
AND w >= T.condition.minWins
THEN unlock(T)
```
**Example:** Frost theme has condition `WinOnDifficulty(Normal)`. Player wins on Normal → Frost unlocked.
---
**C. Component Pool Unlock (rarity tiers)**
```
poolUnlocked(r, d, w) = (
(r == White) OR
(r == Green AND d >= Normal) OR
(r == Blue AND d >= Hard AND w >= 2) OR
(r == Purple AND d >= Expert AND w >= 5) OR
(r == Red AND d >= Nightmare AND w >= 10)
)
FOR each locked component pool P:
IF poolUnlocked(P.rarity, d, w) == true THEN unlock(P)
```
**Example:** Player wins on Hard (d=Hard, w now = 1). Blue pool: `d >= Hard (true), w >= 2 (false)` → not yet. After 2 total wins: Blue pool unlocks.
---
**D. Starting Loadout Unlock (milestone conditions)**
```
FOR each locked loadout L:
IF L.condition.type == TotalWins AND w >= L.condition.targetWins
OR L.condition.type == EnemiesDefeated AND e >= L.condition.targetEnemies
OR L.condition.type == NodesCompleted AND n >= L.condition.targetNodes
OR L.condition.type == Combined AND w >= L.condition.wins
AND e >= L.condition.enemies
THEN unlock(L)
```
**Example:** A loadout with condition `TotalWins(3)` becomes available after the player's 3rd total win.
---
### 2. LifetimeStats.Update
Called on every `RecordRunEnd()` for both win and loss runs.
```
LifetimeStatsUpdate(runStats):
// All runs
totalRunsStarted += 1
totalGoldEarned += runStats.goldEarned
furthestNodeReached = max(furthestNodeReached, runStats.nodesCompleted)
totalNodesCompleted += runStats.nodesCompleted
totalEnemiesDefeated += runStats.enemiesDefeatedThisRun
totalComponentsCollected += runStats.componentsCollectedThisRun
// Win runs only (bossDefeated == true)
IF runStats.bossDefeated == true:
totalWins += 1
winsByDifficulty[runStats.difficulty] += 1
winsByTheme[runStats.theme] += 1
bossesDefeated += 1
```
**Output:** `LifetimeStats` updated in-place. No return value.
---
### 3. StartingBonusResolve
Called at run start when player selects a starting loadout.
```
StartingBonus StartingBonusResolve(loadoutId):
LOADOUT = DRStartingLoadout[loadoutId]
IF LOADOUT == null:
return StartingBonus { goldAmount=0, prebuiltTower=null }
goldAmount = LOADOUT.goldBonus
IF LOADOUT.hasPrebuiltTower == true:
prebuiltTower = AssembleTower(LOADOUT.prebuiltTowerComponents)
ELSE:
prebuiltTower = null
return StartingBonus { goldAmount, prebuiltTower }
```
**Edge case:** If `loadoutId` not found → return `{goldAmount=0, prebuiltTower=null}`. If tower components unavailable → return `{goldAmount=LOADOUT.goldBonus, prebuiltTower=null}`.
---
### 4. Starting Gold Cap Check
When `StartingBonusResolve` returns a gold amount, it is added to the run's starting gold, which is then subject to `MaxPlayerGold` (9999) per the Shop GDD.
```
effectiveStartingGold = Min(defaultStartGold + goldAmount, MaxPlayerGold)
```
`defaultStartGold` comes from `DRRunConfig.StartGold`.
## Edge Cases
- **If player loses any run**: No unlock evaluation. LifetimeStats still updated (run count, gold, furthest node). Losses contribute to stats but not unlocks.
- **If all unlock conditions already satisfied**: `UnlockEvaluation` returns empty array. No-op. Player keeps their unlocks.
- **If save file is corrupted or missing on load**: Initialize fresh `ProgressionData` with defaults (Normal difficulty only, Plain theme only, no bonus loadouts). Log error.
- **If `RecordRunEnd()` is called twice for the same run**: Deduplicated by run ID. Only first call processes.
- **If `totalEnemiesDefeated` or `totalComponentsCollected` would overflow**: Use `long` (Int64) for these cumulative fields. `int` for other counters.
- **If `runStats.goldEarned < 0`**: Treat as 0, log error. Gold should never be negative.
- **If pre-built tower components are not yet unlocked**: Return `{goldAmount=LOADOUT.goldBonus, prebuiltTower=null}`. Log warning. Player can still start run.
- **If `defaultStartGold + goldBonus > MaxPlayerGold` (9999)**: Cap at 9999. Excess discarded.
- **If Alt+F4 mid-run (no RunEnd dispatched)**: No stats recorded for that partial run. Next run starts clean.
- **If two unlocks trigger in same run**: Both appear in the `UnlockResult[]`. `UnlockedEventArgs` fired once with all new unlocks. Player sees both in toast.
- **If difficulty enum is invalid in runStats**: Skip difficulty unlock evaluation. Log error. Other unlocks (themes, pools) still processed.
- **If player wins on Hard but has not unlocked Hard (impossible by SR3)**: `UnlockEvaluation` still processes — the difficulty field in runStats reflects what was played. Player having unlocked Hard is pre-checked at run start, not re-checked at evaluation.
- **If pre-built tower loadout selected but player has no inventory space**: Pre-built tower is placed directly into combat roster (slot 1), not inventory. No inventory space required.
## Dependencies
### Upstream Dependencies (what Progression depends on)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **Node System** | Hard | `RecordRunEnd(runStats)` is called by the Procedure layer after every run end. `runStats` contains all fields defined in the `RunStats` schema (see Detailed Design). No run-level state is stored in Progression between calls. | GDD exists (`design/gdd/node-system.md`) — **Approved** |
| **Shop System** | Soft | Progression reads `GetUnlockedComponentPools()` to determine which component rarities appear in shop. If shop needs to filter by rarity, it calls this method. `MaxPlayerGold = 9999` is owned by `shop.md` — Progression references this constant from there. | GDD exists (`design/gdd/shop.md`) — **Designed** |
### Downstream Dependents (what depends on Progression)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **Node System** | Hard | Node System's `RunEnd` state cannot persist without Progression. All acceptance criteria involving `Progression.RecordRunEnd()` and `Progression.GetLifetimeStats()` are blocked until this GDD is completed. | GDD exists — **Approved**; Progression is the remaining blocker |
| **Shop System** | Soft | Shop may read `GetLifetimeStats()` for conditional text or future features (e.g., "you've spent X gold across all runs"). Not currently required. | GDD exists |
| **UI / New Game Screen** | Hard | Populates difficulty, theme, and starting loadout selection from `GetUnlockedDifficulties()`, `GetUnlockedThemes()`, `GetUnlockedStartingLoadouts()`. | Pending implementation |
| **UI / Profile Screen** | Hard | Displays lifetime statistics from `GetLifetimeStats()`. | Pending implementation |
| **UI / Run End Screen** | Hard | Listens for `OnUnlockedEventArgs`. Shows toast popup listing new unlocks on victory. | Pending implementation |
| **Event System** | Soft | Events may read `GetLifetimeStats()` for conditional flavor text (e.g., "You've won X times"). Not required for MVP. | GDD exists |
### Bidirectional Consistency Check
- [x] Node System → upstream (writes to Progression via `RecordRunEnd`) ✅
- [x] Progression → downstream to Node System (Node System blocked until Progression exists) ✅
- [x] Shop System → reads component pool unlock state ✅
- [ ] Event System → soft dependency, no hard coupling ✅
## Tuning Knobs
All unlock conditions are data-table-driven. No code changes are required to add or modify unlock conditions.
### Data-Driven Tuning Tables
| Table | Controls | Designer Knobs |
|-------|----------|---------------|
| `DRDifficultyTier` | Difficulty unlock chain | `nextTierId`, `unlockConditionType`, `unlockThreshold` |
| `DRTheme` | Theme unlock conditions | `unlockConditionType`, `targetDifficulty`, `targetWins`, `minWins`, `minDifficulty` |
| `DRComponentPool` | Component pool rarity gates | `rarity` (White is always available; Green+ requires unlock), `minDifficulty`, `minWins` |
| `DRStartingLoadout` | Starting loadout conditions and bonuses | `conditionType`, `targetWins`, `targetEnemies`, `targetNodes`, `goldBonus`, `hasPrebuiltTower`, `prebuiltTowerComponents` |
### Runtime Tuning Knobs
| Knob | Default | Safe Range | Extreme: Too Low | Extreme: Too High |
|------|---------|-----------|-----------------|------------------|
| `MaxPlayerGold` | 9999 | 500099999 | Starting bonuses feel large; shop loses tension | Gold feels pointless; player never feels rich | **Owned by `shop.md`** — do not redefine here; reference from shop. |
| `DefaultStartGold` (`DRRunConfig.StartGold`) | varies | 05000 | Player starts too poor; first shop feels bad | Player starts too rich; first shop trivial |
| `DRStartingLoadout.goldBonus` | varies | 05000 | Bonus too low; no meaningful start change | Bonus too high; shop becomes irrelevant |
| `DRComponentPool.minWins` | varies | 050 | Higher pools accessible too early; power spike | Higher pools locked too long; mid-game feels boring |
| `DRTheme UnlockDifficulty` | varies | varies | Easier themes → faster content exhaustion | Harder themes → grindy; "one more run" becomes frustrating |
### Knob Interactions
- **Starting gold bonus + MaxPlayerGold**: If `defaultStartGold + goldBonus > MaxPlayerGold`, excess is silently discarded. Ensure bonuses respect the cap.
- **Difficulty unlock + Component pool**: When a new difficulty tier unlocks, the component pool for that tier becomes available. Ensure pool content (components) exists before the difficulty is unlockable.
- **Theme unlock conditions**: Some themes reference `minWins`. Ensure the intended playtime before unlocking matches the expected pacing curve.
## Visual/Audio Requirements
Progression is a data-layer system with no inherent visual or audio identity. The player's interaction with Progression is mediated entirely through UI screens (New Game, Profile, Toast). No dedicated VFX or audio events are owned by the Progression system itself.
However, the **Toast Popup** (Section UI Requirements) does have visual requirements: the unlock notification should use the game's geometric shape vocabulary (diamonds, triangles, hexagons) consistent with the shop rarity shimmer and node completion VFX.
**Visual Style for Toast Popup:**
- Shape: diamond outline frame around unlock icon
- Rarity color coding: theme unlocks use theme's accent color; difficulty unlocks use difficulty's color; component pool unlocks use the newly available rarity's color
- Entry animation: scale 0.8x → 1.0x, 250ms ease-out
- Particle burst on unlock: geometric particles matching the unlock category
**Audio for Toast Popup:**
- Ascending 3-note arpeggio (C4-E4-G4) at volume 0.5, 200ms
- Distinct from shop purchase arpeggio (which is rarity-keyed and longer)
## UI Requirements
### New Game Screen (Run Setup)
**Trigger**: Player selects "New Run" from main menu.
**Layout**:
- Title: "New Run"
- Difficulty row: horizontal list of unlocked difficulties; locked ones shown as silhouettes with lock icon and tooltip showing unlock condition
- Theme row: horizontal grid of theme cards; locked themes hidden or shown as silhouettes
- Starting Loadout row: horizontal list of owned loadouts; radio-button selection (one active at a time)
- Start Run button: bottom-center, large, disabled until at least one valid combination is selected
**Unlock Feedback on Screen:**
- When a new unlock becomes available between when the player last viewed New Game and pressed Start, the newly available item pulses briefly (once) to draw attention
**Constraint**: Player cannot select a locked difficulty or theme. UI enforces this; no runtime validation needed.
---
### Profile Screen (Lifetime Statistics)
**Trigger**: Player selects "Profile" or "Stats" from main menu.
**Layout**:
- Header: total runs, total wins, win rate (percentage)
- Primary stats grid: Total Gold Earned, Furthest Node Reached (110), Bosses Defeated
- Secondary stats (tabbed or collapsible): Components Collected, Wins by Difficulty (table), Wins by Theme (table)
- No editing — display only
**Constraint**: All stats are read from `GetLifetimeStats()`. No modification is possible from this screen.
---
### Toast Popup (Unlock Notification)
**Trigger**: `OnUnlockedEventArgs` fires on `RecordRunEnd` with non-empty `UnlockResult[]`.
**Layout**:
- Position: bottom-center of screen, above Run End screen content
- Content: unlock category icon (geometric shape), unlocked item name, "UNLOCKED" label
- Shape: diamond outline frame
- Entry: scale 0.8x → 1.0x, 250ms ease-out
- Exit: fade out over 200ms on click or after 5 seconds
**Constraints**:
- Multiple unlocks in same run: show one toast per item, staggered 300ms apart
- If player clicks away immediately, toast dismisses immediately — do not block
---
### Accessibility
- All unlock conditions shown as text in tooltip (no icon-only indicators)
- Toast is keyboard-accessible (Enter/Space to dismiss)
- Profile screen stats are screen-reader friendly with labeled values
## Acceptance Criteria
### Data Persistence
- **GIVEN** a new player starts the game for the first time, **WHEN** they begin a run, **THEN** only Normal difficulty, Plain theme, and the default starting loadout are available.
- **GIVEN** the player's save file is corrupted or missing, **WHEN** the game loads, **THEN** a fresh `ProgressionData` is initialized with defaults (Normal only, Plain only, no bonus loadouts) and no crash occurs.
### Unlock Evaluation
- **GIVEN** a player completes a run with `bossDefeated=false` (loss), **WHEN** `RecordRunEnd` is called, **THEN** `totalRunsStarted` increments, `totalGoldEarned` increases by `goldEarned`, `furthestNodeReached` and `totalNodesCompleted` update correctly, but no unlock evaluation occurs.
- **GIVEN** a player defeats the Boss on Normal difficulty for the first time, **WHEN** `RecordRunEnd` is called with `bossDefeated=true` and `difficulty=Normal`, **THEN** Hard difficulty is unlocked and appears in the New Game screen.
- **GIVEN** a player defeats the Boss on Hard difficulty, **WHEN** `RecordRunEnd` is called, **THEN** Expert difficulty is unlocked.
- **GIVEN** a player defeats the Boss on Expert difficulty, **WHEN** `RecordRunEnd` is called, **THEN** Nightmare difficulty is unlocked.
### Theme Unlock
- **GIVEN** a theme has `UnlockCondition = WinOnDifficulty(Normal)` and the player defeats the Boss on Normal, **WHEN** `RecordRunEnd` completes, **THEN** that theme appears in the theme selection on the New Game screen.
- **GIVEN** a theme has `UnlockCondition = TotalWins(5)` and the player earns their 5th total win, **WHEN** `RecordRunEnd` completes, **THEN** that theme becomes available in the New Game screen.
### Component Pool Unlock
- **GIVEN** a player wins on Normal difficulty for the first time, **WHEN** `RecordRunEnd` completes, **THEN** the Green component pool is unlocked and Green rarity components appear in the shop's offered goods.
- **GIVEN** a player has 2+ wins and wins on Hard difficulty, **WHEN** `RecordRunEnd` completes, **THEN** the Blue component pool is unlocked.
### Starting Loadout
- **GIVEN** a player has 3 total wins and has unlocked Hard, **WHEN** they complete a run on Hard difficulty, **THEN** any Starting Loadout with condition `TotalWins(3)` becomes unlocked and visible in the loadout selection.
- **GIVEN** a player selects a Starting Loadout with `goldBonus=500`, **WHEN** the run begins, **THEN** `PlayerInventoryComponent.Gold` equals `defaultStartGold + 500`, capped at `MaxPlayerGold` (9999).
- **GIVEN** a player selects a Starting Loadout with a pre-built tower, **WHEN** the run begins, **THEN** slot 1 of the combat roster contains the pre-built tower assembled from the loadout's components.
- **GIVEN** a player's pre-built tower loadout references components not yet unlocked, **WHEN** `StartingBonusResolve` is called, **THEN** the gold bonus is applied, `prebuiltTower` is `null`, and a warning is logged.
### Lifetime Stats (All Runs)
- **GIVEN** a player completes 3 runs with `nodesCompleted` of 4, 7, and 5 respectively, **WHEN** `LifetimeStats` is queried, **THEN** `totalNodesCompleted` equals 16.
- **GIVEN** a player Alt+F4s mid-run without triggering `RunEnd`, **WHEN** the game restarts, **THEN** no stats from that partial run appear in `LifetimeStats`.
### Lifetime Stats (Win Runs Only)
- **GIVEN** a player wins a run, **WHEN** `RecordRunEnd` is called with `bossDefeated=true`, **THEN** `totalWins` increments, `winsByDifficulty[difficulty]` increments, `winsByTheme[theme]` increments, and `bossesDefeated` increments.
- **GIVEN** `totalEnemiesDefeated` would exceed `int.MaxValue` with a large cumulative value, **WHEN** `LifetimeStats.Update` is called, **THEN** the field uses `long` (Int64) and does not overflow.
### Unlock Feedback
- **GIVEN** a player wins a run that triggers two new unlocks simultaneously, **WHEN** `RecordRunEnd` completes, **THEN** the Run End screen shows a toast popup listing all newly unlocked items.
### UI Interface (Getter Methods)
- **GIVEN** a player has unlocked Hard difficulty and 2 themes, **WHEN** `GetUnlockedDifficulties()` is called, **THEN** it returns `[Normal, Hard]`.
- **GIVEN** a player has unlocked the Frost theme and the Default theme, **WHEN** `GetUnlockedThemes()` is called, **THEN** it returns `[Default, Frost]`.
- **GIVEN** a player has unlocked 1 starting loadout, **WHEN** `GetUnlockedStartingLoadouts()` is called, **THEN** it returns a list containing exactly that one loadout.
- **GIVEN** a player has completed 5 runs (2 wins, 3 losses), **WHEN** `GetLifetimeStats()` is called, **THEN** `totalRunsStarted = 5`, `totalWins = 2`, and `furthestNodeReached` reflects the max of all runs.
- **GIVEN** a player has `totalGoldEarned = 5000` and selects a loadout with `goldBonus = 200`, **WHEN** `StartingBonusResolve(loadoutId)` is called, **THEN** `effectiveStartingGold = Min(defaultStartGold + 200, MaxPlayerGold = 9999)`.
- **GIVEN** `GetUnlockedComponentPools()` is called, **WHEN** the player has won once on Normal, **THEN** it returns White and Green pools (Green requires d >= Normal).
### Edge Cases
- **GIVEN** `RecordRunEnd` has already been called for run ID "abc123", **WHEN** it is called again with the same run ID, **THEN** `LifetimeStats` only increments once and only one unlock evaluation occurs.
- **GIVEN** `runStats.goldEarned` is negative (e.g., 100) due to an upstream bug, **WHEN** `LifetimeStats.Update` is called, **THEN** `totalGoldEarned` increases by 0, not 100, and the error is logged.
## Open Questions
### 1. Standalone Binary Achievements
**Status**: OPEN — Should there be standalone binary achievements separate from the 4 unlock categories (e.g., "Defeat 100 Bosses", "Collect 1000 Components", "Win on all difficulties")? These would be display-only badges with no gameplay effect. Currently all unlocks are gated by the 4 categories. Adding achievements as a 5th category would increase content without adding mechanical variety.
### 2. Cloud Save Sync
**Status**: OPEN — Any cloud sync considerations for `ProgressionData` across devices? Single-player desktop game — likely local-only. If multiplayer or cross-device play is ever added, ProgressionData would need serialization parity and conflict resolution.
### 3. Starting Loadout Components Catalog
**Status**: OPEN — When a pre-built tower loadout references `prebuiltTowerComponents`, which component pool do those components draw from? If the referenced components are from a pool the player hasn't unlocked yet, `StartingBonusResolve` returns null tower (per edge case). Should pre-built loadouts source from a special "Starter" component pool that's always available, rather than the normal pool?
### 4. "First Time" Callback to Audio/VFX
**Status**: OPEN — The GDD specifies a Toast popup for unlock feedback. Should there also be a dedicated "first time ever" callback signal that the audio system can hook for a distinct sound on the very first unlock of any category? Or is the Toast audio sufficient?