# Progression (成长系统) > **Status**: Designed > **Author**: SepComet + agents > **Last Updated**: 2026-04-29 > **Implements Pillar**: [To be defined in game-pillars.md — Progression serves the "collection and mastery" fantasy; pillar text not yet written] ## 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*. 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`, `CompletionCounts`, and `LifetimeStats`. **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. 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`. 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. ### 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 | 0–10 | Nodes cleared this run | **Output Range:** 0 to N newly unlocked items per run. In practice, typically 0–2. --- **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:** "Starter Pack" loadout has condition `TotalWins(3)`. After 3rd win → unlocked. --- ### 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 `{difficulty, theme, goldEarned, nodesCompleted, bossDefeated, coinsEarned, componentsDropped}`. No run-level state is stored in Progression between calls. | GDD exists (`design/gdd/node-system.md`) — In Review | | **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. | GDD exists (`design/gdd/shop.md`) — In Design | ### 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 — In Review; blocked by this GDD | | **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`, `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 | 5000–99999 | Starting bonuses feel large; shop loses tension | Gold feels pointless; player never feels rich | | `DefaultStartGold` (`DRRunConfig.StartGold`) | varies | 0–5000 | Player starts too poor; first shop feels bad | Player starts too rich; first shop trivial | | `DRStartingLoadout.goldBonus` | varies | 0–5000 | Bonus too low; no meaningful start change | Bonus too high; shop becomes irrelevant | | `DRComponentPool.minWins` | varies | 0–50 | 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 (1–10), 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. ### 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?