diff --git a/docs/plans/2026-02-15-skill-tree-progression-system.md b/docs/plans/2026-02-15-skill-tree-progression-system.md new file mode 100644 index 0000000..0cb879e --- /dev/null +++ b/docs/plans/2026-02-15-skill-tree-progression-system.md @@ -0,0 +1,507 @@ +# Skill Tree Progression System & Whitespace Support + +## Context + +keydr currently tracks only a-z lowercase letters in its adaptive unlock system. Since keydr aims to be a coding-focused typing tutor, it must also train capitals, numbers, punctuation, whitespace (tabs/newlines), and code-specific symbols. The current flat a-z progression needs to be replaced with a branching skill tree that lets players choose their training path after mastering lowercase letters. Additionally, code drills currently strip newlines into spaces, making them unrealistic for real-world code practice. + +## Skill Tree Structure + +The tree is flat: a-z is the root, and all other branches are direct siblings at the same level. Once a-z is complete, all branches unlock simultaneously and the user can choose any order. + +``` + ┌─────────────────┐ + │ a-z Lowercase │ (ROOT - everyone starts here) + │ 26 keys, freq │ + │ order unlock │ + └────────┬────────┘ + │ + ┌─────────┬──────────┼──────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ + ┌─────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐ + │Capitals │ │Numbers │ │ Prose │ │White- │ │ Code │ + │ A-Z │ │ 0-9 │ │ Punct. │ │ space │ │ Symbols │ + │ 3 lvls │ │ 2 lvls │ │ 3 lvls │ │ 2 lvls │ │ 4 lvls │ + └─────────┘ └────────┘ └────────┘ └────────┘ └──────────┘ +``` + +### Prerequisites + +- **a-z Lowercase** (root): Always available from start +- **All other branches**: Require a-z complete (all 26 lowercase letters confident). Once a-z is done, all 5 branches unlock simultaneously. User freely chooses which to pursue. + +--- + +## Branch Status State Machine + +Each branch has an explicit status: + +```rust +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum BranchStatus { + Locked, // Prerequisites not met + Available, // Prerequisites met, user hasn't started + InProgress, // User has begun drilling this branch + Complete, // All levels in branch are done +} +``` + +**Transitions:** +- `Locked → Available`: When a-z branch reaches `Complete` +- `Available → InProgress`: **Only** when user explicitly launches a branch drill from the skill tree (start-on-select model). The global adaptive drill does NOT auto-start branches. +- `InProgress → Complete`: When all keys in all levels of the branch reach confidence >= 1.0 + +**Multiple branches active**: Yes. The user can have multiple branches `InProgress` simultaneously. Each tracks its own current level independently. + +**Global adaptive scope**: Only includes keys from `InProgress` and `Complete` branches. `Available` branches are not included — the user must visit the skill tree to start them. + +--- + +## Detailed Level Breakdown + +### Branch: a-z Lowercase (Root) + +Uses existing frequency-order system. Starts with 6 keys, unlocks one at a time when all current keys reach confidence >= 1.0. Branch is "complete" when all 26 letters are confident. + +Order: `e t a o i n s h r d l c u m w f g y p b v k j x q z` + +Total keys: **26** + +### Branch: Capital Letters (3 levels) + +- **Level 1 — Common Sentence Capitals** (8 keys): `T I A S W H B M` +- **Level 2 — Name Capitals** (10 keys): `J D R C E N P L F G` +- **Level 3 — Remaining Capitals** (8 keys): `O U K V Y X Q Z` + +Total keys: **26** + +Text generation rules: +- First word of each "sentence" (after `.` `?` `!` or at drill start) gets capitalized +- ~10-15% of words get capitalized as proper-noun-like words +- Focused capital letter is boosted (40% chance to appear in word starts) + +### Branch: Numbers (2 levels) + +- **Level 1 — Common Digits** (5 keys): `1 2 3 4 5` +- **Level 2 — All Digits** (5 keys): `0 6 7 8 9` + +Total keys: **10** + +Text generation rules: +- ~15% of words replaced with number expressions using only unlocked digits +- Patterns: counts ("3 items"), years ("2024"), IDs ("room 42"), measurements ("7 miles") + +### Branch: Prose Punctuation (3 levels) + +- **Level 1 — Essential** (3 keys): `. , '` +- **Level 2 — Common** (4 keys): `; : " -` +- **Level 3 — Expressive** (4 keys): `? ! ( )` + +Total keys: **11** + +Text generation rules follow natural prose patterns: +- `.` ends sentences (every 5-15 words), `,` separates clauses +- `'` in contractions (don't, it's, we'll) +- `"` wrapping quoted phrases, `;` between clauses, `:` before lists +- `-` in compound words (well-known), `?` for questions, `!` for exclamations +- `( )` for parenthetical asides + +### Branch: Whitespace (2 levels) + +- **Level 1 — Enter/Return** (1 key): `\n` +- **Level 2 — Tab/Indent** (1 key): `\t` + +Total keys: **2** + +Text generation rules: +- Line breaks at sentence boundaries (every ~60-80 chars) +- Tabs for indentation in code-like structures +- Once unlocked, **default adaptive drills automatically become multi-line** + +### Branch: Code Symbols (4 levels) + +- **Level 1 — Arithmetic & Assignment** (5 keys): `= + * /` and `-` (shared with Prose Punct L2) +- **Level 2 — Grouping** (6 keys): `{ } [ ] < >` +- **Level 3 — Logic & Reference** (5 keys): `& | ^ ~` and `!` (shared with Prose Punct L3) +- **Level 4 — Special** (7 keys): `` @ # $ % _ \ ` `` + +Total keys: **23** (21 unique + 2 shared with Prose Punctuation) + +Text generation rules: +- L1: Prose with simple expressions (`x = a + b`, `total = price * qty`) +- L2: Code-pattern templates (`if (x) { return y; }`, `arr[0]`) +- L3: Bitwise/logical patterns (`a & b`, `!flag`, `*ptr`) +- L4: Language-specific patterns (`@decorator`, `#include`, `snake_case`) + +**Grand total**: 98 keys across branches, **96 unique keys** (after deducting 2 shared: `-` and `!`). `TOTAL_UNIQUE_KEYS` is derived at startup by collecting all keys from all branch definitions into a `HashSet` and taking `len()`. Stored as a field on `SkillTree` for use in scoring and UI. + +--- + +## Shared Keys Between Branches + +Two keys appear in multiple branches: +- `-` appears in Prose Punctuation L2 and Code Symbols L1 +- `!` appears in Prose Punctuation L3 and Code Symbols L3 + +**Rule**: Confidence is tracked once per character in `KeyStatsStore` (keyed by `char`). If a user masters `-` in Prose Punctuation, it is automatically confident in Code Symbols too. When checking level completion, the branch reads the single confidence value for that char. This is idempotent — no special handling needed. + +--- + +## Focused Key Policy + +### Global Adaptive Drill (from menu) + +1. Collect all keys from all `InProgress` branches (current level's keys only) plus all `Complete` branch keys +2. Find the key with the **lowest confidence < 1.0** across this entire set +3. If all keys are confident, no focused key (maintenance mode) +4. Boost the focused key in text generation (40% probability) + +### Branch-Specific Drill (from skill tree) + +1. Collect keys from the selected branch including **all prior completed levels** (as background reinforcement) plus the **current level's keys**, plus all a-z keys +2. Find the key with the **lowest confidence < 1.0** within the **current level keys only** (prior level keys are reinforcement, not focus targets) +3. If all current level keys are confident, advance the level and focus on the weakest new key +4. Boost the focused key in text generation (40% probability) +5. Prior-level keys always appear in generated text for reinforcement but are never the focused key + +### Branches with Zero Progress + +When a branch is `Available` but user hasn't started it yet: +- Launching a drill from that branch transitions it to `InProgress` at level 1 +- The focused key is the weakest among level 1's keys (likely all at 0.0 confidence, so pick the first in definition order) + +--- + +## Scoring + +Current formula: `complexity = unlocked_count / 26` + +**New formula**: `complexity = total_unlocked_keys / TOTAL_UNIQUE_KEYS` + +Where `TOTAL_UNIQUE_KEYS = 96` is computed from branch definitions (deduplicated across shared keys). This scales naturally — the more branches the user has unlocked, the higher the complexity multiplier. + +Level formula remains: `level = floor(sqrt(total_score / 100))`. + +Menu header changes from `"X/26 letters"` to `"X/96 keys"`. + +--- + +## Skill Tree UI + +### New Screen: `AppScreen::SkillTree` + +Accessible from menu via `[t] Skill Tree`. Renders **vertically** as a scrollable list. + +``` +╔══════════════════════════════════════════════════════════════════╗ +║ SKILL TREE ║ +╠══════════════════════════════════════════════════════════════════╣ +║ ║ +║ ★ Lowercase a-z COMPLETE 26/26 ║ +║ ████████████████████████████████████████ Level 26/26 ║ +║ ║ +║ ── Branches (unlocked after a-z) ────────────────────────── ║ +║ ║ +║ ► Capitals A-Z Lvl 2/3 18/26 keys ║ +║ ████████████████████░░░░░░░░░░░░ 69% ║ +║ ║ +║ Numbers 0-9 Lvl 0/2 0/10 keys ║ +║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║ +║ ║ +║ Prose Punctuation Lvl 1/3 3/11 keys ║ +║ ██████████░░░░░░░░░░░░░░░░░░░░░ 27% ║ +║ ║ +║ Whitespace Lvl 0/2 0/2 keys ║ +║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║ +║ ║ +║ Code Symbols Lvl 0/4 0/23 keys ║ +║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║ +║ ║ +╠══════════════════════════════════════════════════════════════════╣ +║ ► Capitals A-Z Level 2/3 ║ +║ L1: T I A S W H B M (complete) ║ +║ L2: J [D] R C E N P L F G (in progress, focused: D) ║ +║ L3: O U K V Y X Q Z (locked) ║ +║ Avg Confidence: ████████░░ 82% ║ +║ ║ +║ [Enter] Start Drill [↑↓/jk] Navigate [q] Back ║ +╚══════════════════════════════════════════════════════════════════╝ +``` + +**Layout:** +- **Top section**: Vertical list of all branches with status prefix, level, key count, progress bar +- **Bottom section**: Detail panel showing per-level key breakdown, confidence bars, focused key +- **Footer**: Controls + +**Node states (prefix):** +- Locked: grayed out, no prefix, not selectable +- Available: normal color, no prefix +- In Progress `►`: accent color +- Complete `★`: gold/green + +**Navigation:** `↑↓` / `j/k` move selection. `Enter` launches branch drill. `q` returns to menu. + +**Keyboard diagram**: For non-printable keys (`Enter`, `Tab`), show them as labeled keys on the keyboard diagram in their standard positions. No special handling needed — they're physical keys with fixed positions. + +--- + +## Code & Passage Drill Changes (Unranked Modes) + +Code and Passage drills remain as separate menu options. + +1. **Unranked tagging**: Add `ranked: bool` to `DrillResult` with `#[serde(default = "default_true")]` for backward compat +2. **Derive ranked from DrillContext**: At drill start, set `ranked = (drill_mode == Adaptive)`. Code/Passage → `ranked = false`. +3. **No progression**: `finish_drill()` gates skill tree updates on `result.ranked` +4. **History replay**: `rebuild_from_history()` uses `result.ranked` as the gate. No legacy fallback — since we reset on schema change (WIP policy), old history without `ranked` field won't exist. +5. **Visual indicators**: + - Drill header: "Code Drill (Unranked)" / "Passage Drill (Unranked)" in dimmed/muted color + - Result screen: "Unranked — does not count toward skill tree" + - Stats dashboard history: unranked rows shown with muted styling + +--- + +## Whitespace Handling + +### Tokenized Render Model (`typing_area.rs`) + +Replace direct char→span rendering with a `RenderToken` approach to handle one-to-many char-to-cell mapping: + +```rust +struct RenderToken { + target_idx: usize, // Index into DrillState.target + display: String, // What to show (e.g., "↵", "→···", "a") + style: Style, // Computed style (correct/incorrect/cursor/pending) +} +``` + +**Display mapper:** +- `\n` → visible `↵` marker token + hard line break (new `Line` in paragraph) +- `\t` → visible `→` marker + padding `·` tokens to next 4-char tab stop +- All other chars → single token with char as display + +**Cursor/style mapping:** Maintain a `Vec<(usize, usize)>` mapping from `target_idx` to first display cell position. When highlighting cursor or errors, look up the target index to find which display tokens to style. + +**Multi-line rendering:** Change from single `Line` to `Vec`. Split on newline tokens. Each line is a separate `Line` in the `Paragraph`. + +### Input Pipeline (`main.rs` + `session/input.rs`) + +Current flow: `main.rs` matches `KeyCode::Char(ch)` → `app.type_char(ch)`. Enter/Tab are currently consumed by other handlers (menu nav, etc.). + +**Changes in `main.rs`:** +- When `screen == Drill` and drill is active: + - `KeyCode::Enter` → `app.type_char('\n')` **unconditionally** (correctness decided by `process_char()`) + - `KeyCode::Tab` → `app.type_char('\t')` **unconditionally** (correctness decided by `process_char()`) + - `KeyCode::BackTab` (Shift+Tab) → ignore (no action) + - These must be checked **before** the existing Esc/Enter handlers for drill screen + - If Enter/Tab is typed when not expected, it registers as an error on the current char — same as typing any wrong key + +**No changes to `session/input.rs`**: `process_char()` already compares `ch == expected` generically. It will work with `'\n'` and `'\t'` as-is. + +### Code Drill Updates (`generator/code_syntax.rs`) + +- Embedded snippets change from single-line `&str` to multi-line string literals with preserved indentation +- `extract_code_snippets()`: preserve original newlines and leading whitespace instead of `split_whitespace().join(" ")` +- `generate()`: join snippets with `\n\n` instead of `" "` + +--- + +## Data Model Changes + +### Persistence Policy (WIP stage) + +**No backward compatibility migration.** On schema mismatch, reset persisted files to defaults. Bump schema version to 2. Add a note in changelog that local progress is intentionally reset for this version. This avoids over-engineering migration logic during early development. + +### `ProfileData` (schema v2) + +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ProfileData { + pub schema_version: u32, // 2 + pub skill_tree: SkillTreeProgress, // Replaces unlocked_letters + pub total_score: f64, + pub total_drills: u32, + pub streak_days: u32, + pub best_streak: u32, + pub last_practice_date: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SkillTreeProgress { + pub branches: HashMap, // String keys for stable JSON +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BranchProgress { + pub status: BranchStatus, + pub current_level: usize, // 0-indexed into branch's levels array + // current_level = 0 means working on first level (plan's "Level 1") + // current_level = levels.len() only when status == Complete +} +``` + +**Indexing invariant**: `current_level` is always 0-indexed into `BranchDefinition.levels`. When the plan says "Level 1", "Level 2", etc. in human-readable text, that maps to `current_level = 0`, `current_level = 1`, etc. in code. A branch with `current_level = 0` and `status = InProgress` is actively working on its first level. + +**HashMap uses `String` keys** (e.g., `"lowercase"`, `"capitals"`, `"numbers"`, etc.) for stable JSON serialization. `BranchId` enum has `to_key() -> &'static str` and `from_key()` methods. + +### `DrillResult` Addition + +```rust +#[serde(default = "default_true")] +pub ranked: bool, +``` + +### `KeyStatsStore` + +No structural change. Already `HashMap` — works for any char. + +--- + +## Skill Tree Definition (Source of Truth) + +Hard-coded static definition in `src/engine/skill_tree.rs`: + +```rust +pub struct BranchDefinition { + pub id: BranchId, + pub name: &'static str, + pub levels: Vec, +} + +pub struct LevelDefinition { + pub name: &'static str, + pub keys: Vec, +} +``` + +All branch/level/key definitions are `const`/`static` arrays. No data-driven manifest needed at this stage. The `SkillTree` struct holds: +- The static definition (reference) +- The persisted `SkillTreeProgress` (mutable state) +- Methods: `unlocked_keys(scope)`, `focused_key(scope, &KeyStatsStore)`, `update(&KeyStatsStore)`, `branch_status(id)`, `all_branches()` + +--- + +## Implementation Phases + +### Phase 1: Skill Tree Core & Data Model + +**Goal**: Replace `LetterUnlock` with `SkillTree`, update persistence. + +1. Create `src/engine/skill_tree.rs`: + - `BranchId` enum (`Lowercase, Capitals, Numbers, ProsePunctuation, Whitespace, CodeSymbols`) + - `BranchStatus` enum (`Locked, Available, InProgress, Complete`) + - `BranchDefinition`, `LevelDefinition` structs + - Static branch definitions with all keys per level + - `SkillTree` struct with `update()`, `unlocked_keys()`, `focused_key()`, `branch_status()` +2. Update `src/store/schema.rs`: new `ProfileData` with `SkillTreeProgress`, schema v2, reset on mismatch +3. Add `ranked: bool` to `DrillResult` in `src/session/result.rs` +4. Update `src/app.rs`: replace `letter_unlock: LetterUnlock` with `skill_tree: SkillTree`, update `finish_drill()` to gate on `ranked`, update `rebuild_from_history()`, update scoring complexity formula +5. Delete/replace `src/engine/letter_unlock.rs` + +**Key files**: `src/engine/skill_tree.rs` (new), `src/engine/letter_unlock.rs` (delete), `src/store/schema.rs`, `src/session/result.rs`, `src/app.rs` + +**Tests**: +- Skill tree status transitions (Locked → Available → InProgress → Complete) +- Shared key confidence propagation +- Focused key selection (global vs branch scope) +- Level completion and advancement +- Schema reset on version mismatch + +**Acceptance criteria**: `cargo build` passes, `cargo test` passes, existing adaptive drills work with skill tree (a-z only), scoring uses new formula. + +### Phase 2: Whitespace Input & Rendering + +**Goal**: Support Enter/Tab in typing drills with proper display. + +1. Update `src/ui/components/typing_area.rs`: tokenized render model with `RenderToken`, multi-line support, visible `↵` and `→` markers +2. Update `src/main.rs`: route `KeyCode::Enter` → `'\n'` and `KeyCode::Tab` → `'\t'` when in drill mode, ignore `BackTab` +3. Update `src/generator/code_syntax.rs`: preserve newlines/indentation in snippets, change embedded snippets to multi-line, fix `extract_code_snippets()` to preserve whitespace +4. Optionally update `src/generator/passage.rs` with multi-line passage variants + +**Key files**: `src/ui/components/typing_area.rs`, `src/main.rs`, `src/generator/code_syntax.rs` + +**Tests**: +- RenderToken generation for strings with `\n` and `\t` +- Cursor position mapping with expanded tokens +- Enter/Tab input processing (reuse existing `process_char()` — just verify `'\n'` and `'\t'` work) + +**Acceptance criteria**: Code drills display multi-line with visible whitespace markers, Enter/Tab advance the cursor correctly, backspace works across line boundaries. + +### Phase 3: Text Generation for Capitals & Punctuation + +**Goal**: Generate drill text that naturally incorporates capitals and punctuation. + +1. Create `src/generator/capitalize.rs`: post-processing pass that capitalizes sentence starts and occasional words, using only unlocked capital letters +2. Create `src/generator/punctuate.rs`: post-processing pass that inserts periods, commas, apostrophes, etc. at natural positions, using only unlocked punctuation +3. Update `src/generator/phonetic.rs` or `src/app.rs` `generate_text()`: apply capitalize/punctuate passes when those branches are active +4. Update `src/engine/filter.rs` `CharFilter`: add awareness of which char types are allowed (lowercase, uppercase, punctuation, etc.) + +**Key files**: `src/generator/capitalize.rs` (new), `src/generator/punctuate.rs` (new), `src/generator/phonetic.rs`, `src/app.rs`, `src/engine/filter.rs` + +**Acceptance criteria**: Adaptive drills with Capitals branch active produce properly capitalized text. Drills with Prose Punctuation active have natural punctuation placement. + +### Phase 4: Text Generation for Numbers & Code Symbols + +**Goal**: Generate drill text with numbers and code symbol patterns. + +1. Create `src/generator/numbers.rs`: injects number expressions into prose using only unlocked digits +2. Create `src/generator/code_patterns.rs`: code-pattern templates for Code Symbols branch drills (expressions, brackets, operators) +3. Update `src/app.rs` `generate_text()`: apply number/code passes based on active branches +4. For whitespace branch: when active, insert `\n` at sentence boundaries in generated text + +**Key files**: `src/generator/numbers.rs` (new), `src/generator/code_patterns.rs` (new), `src/app.rs` + +**Acceptance criteria**: Number expressions use only unlocked digits. Code symbol drills produce recognizable code-like patterns. Whitespace branch generates multi-line output. + +### Phase 5: Skill Tree UI + +**Goal**: Navigable skill tree screen with branch detail and drill launch. + +1. Add `AppScreen::SkillTree` to `src/app.rs` +2. Create `src/ui/components/skill_tree.rs`: vertical branch list + detail panel widget +3. Update `src/main.rs`: handle key events for skill tree screen (navigation, drill launch) +4. Update `src/ui/components/menu.rs`: add `[t] Skill Tree` option +5. Update menu header: show `"X/96 keys"` instead of `"X/26 letters"` +6. Add `DrillMode::BranchDrill(BranchId)` or similar to track drill origin for branch-specific focus + +**Key files**: `src/ui/components/skill_tree.rs` (new), `src/app.rs`, `src/main.rs`, `src/ui/components/menu.rs` + +**Acceptance criteria**: Can navigate to skill tree from menu, see all branches with correct status, launch a branch-specific drill, return to menu. + +### Phase 6: Unranked Mode Polish + +**Goal**: Clearly distinguish ranked vs unranked drills in UI. + +1. Update drill header in `src/main.rs`: show "(Unranked)" for Code/Passage modes +2. Update `src/ui/components/dashboard.rs` result screen: note "does not count toward skill tree" +3. Update `src/ui/components/stats_dashboard.rs`: muted styling for unranked history rows +4. Verify `rebuild_from_history()` correctly uses `ranked` field to gate skill tree updates + +**Key files**: `src/main.rs`, `src/ui/components/dashboard.rs`, `src/ui/components/stats_dashboard.rs`, `src/app.rs` + +**Acceptance criteria**: Code/Passage drills clearly marked unranked. Stats history shows visual distinction. Ranked drills advance skill tree, unranked don't. + +--- + +## Verification + +### Automated Tests + +- **Skill tree transitions**: `Locked → Available → InProgress → Complete` for each branch +- **Shared keys**: Mastering `!` in Prose Punct → confident in Code Symbols too +- **Focused key**: Global scope selects weakest across all active branches; branch scope selects within branch +- **Level advancement**: Completing all keys in a level auto-advances to next +- **Ranked/unranked**: Only ranked drills update skill tree in `rebuild_from_history()` +- **Whitespace tokens**: RenderToken expansion for `\n` and `\t` produces correct display strings and index mapping +- **Input routing**: `'\n'` and `'\t'` correctly processed as typed characters + +### Manual Testing + +1. Launch app → a-z trunk works as before +2. Complete a-z (or edit profile to simulate) → all 5 branches show as Available +3. Navigate skill tree → select Capitals → launch drill → see capitalized text +4. Complete Capitals L1 → L2 keys appear in drills +5. Launch default adaptive with multiple branches active → text mixes all unlocked keys +6. Launch Code/Passage drill → header shows "(Unranked)", no skill tree progress +7. Start Whitespace branch → default adaptive becomes multi-line +8. Type Enter/Tab in code drills → cursor advances correctly, errors tracked +9. Quit and relaunch → progress preserved +10. Delete `~/.local/share/keydr/` → app resets cleanly to fresh state diff --git a/src/app.rs b/src/app.rs index 4db6e6d..28610d5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,12 +7,16 @@ use rand::SeedableRng; use crate::config::Config; use crate::engine::filter::CharFilter; use crate::engine::key_stats::KeyStatsStore; -use crate::engine::letter_unlock::LetterUnlock; use crate::engine::scoring; +use crate::engine::skill_tree::{BranchId, BranchStatus, DrillScope, SkillTree}; +use crate::generator::capitalize; +use crate::generator::code_patterns; use crate::generator::code_syntax::CodeSyntaxGenerator; use crate::generator::dictionary::Dictionary; +use crate::generator::numbers; use crate::generator::passage::PassageGenerator; use crate::generator::phonetic::PhoneticGenerator; +use crate::generator::punctuate; use crate::generator::TextGenerator; use crate::generator::transition_table::TransitionTable; @@ -31,6 +35,7 @@ pub enum AppScreen { DrillResult, StatsDashboard, Settings, + SkillTree, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -48,11 +53,16 @@ impl DrillMode { DrillMode::Passage => "passage", } } + + pub fn is_ranked(self) -> bool { + matches!(self, DrillMode::Adaptive) + } } pub struct App { pub screen: AppScreen, pub drill_mode: DrillMode, + pub drill_scope: DrillScope, pub drill: Option, pub drill_events: Vec, pub last_result: Option, @@ -61,7 +71,7 @@ pub struct App { pub theme: &'static Theme, pub config: Config, pub key_stats: KeyStatsStore, - pub letter_unlock: LetterUnlock, + pub skill_tree: SkillTree, pub profile: ProfileData, pub store: Option, pub should_quit: bool, @@ -71,6 +81,7 @@ pub struct App { pub last_key_time: Option, pub history_selected: usize, pub history_confirm_delete: bool, + pub skill_tree_selected: usize, rng: SmallRng, transition_table: TransitionTable, #[allow(dead_code)] @@ -86,22 +97,31 @@ impl App { let store = JsonStore::new().ok(); - let (key_stats, letter_unlock, profile, drill_history) = if let Some(ref s) = store { - let ksd = s.load_key_stats(); + let (key_stats, skill_tree, profile, drill_history) = if let Some(ref s) = store { + // load_profile returns None if file exists but can't parse (schema mismatch) let pd = s.load_profile(); - let lhd = s.load_drill_history(); - let lu = if pd.unlocked_letters.is_empty() { - LetterUnlock::new() - } else { - LetterUnlock::from_included(pd.unlocked_letters.clone()) - }; - - (ksd.stats, lu, pd, lhd.drills) + match pd { + Some(pd) if !pd.needs_reset() => { + let ksd = s.load_key_stats(); + let lhd = s.load_drill_history(); + let st = SkillTree::new(pd.skill_tree.clone()); + (ksd.stats, st, pd, lhd.drills) + } + _ => { + // Schema mismatch or parse failure: full reset of all stores + ( + KeyStatsStore::default(), + SkillTree::default(), + ProfileData::default(), + Vec::new(), + ) + } + } } else { ( KeyStatsStore::default(), - LetterUnlock::new(), + SkillTree::default(), ProfileData::default(), Vec::new(), ) @@ -116,6 +136,7 @@ impl App { let mut app = Self { screen: AppScreen::Menu, drill_mode: DrillMode::Adaptive, + drill_scope: DrillScope::Global, drill: None, drill_events: Vec::new(), last_result: None, @@ -124,7 +145,7 @@ impl App { theme, config, key_stats: key_stats_with_target, - letter_unlock, + skill_tree, profile, store, should_quit: false, @@ -134,6 +155,7 @@ impl App { last_key_time: None, history_selected: 0, history_confirm_delete: false, + skill_tree_selected: 0, rng: SmallRng::from_entropy(), transition_table, dictionary, @@ -155,13 +177,84 @@ impl App { match mode { DrillMode::Adaptive => { - let filter = CharFilter::new(self.letter_unlock.included.clone()); - let focused = self.letter_unlock.focused; + let scope = self.drill_scope; + let all_keys = self.skill_tree.unlocked_keys(scope); + let focused = self.skill_tree.focused_key(scope, &self.key_stats); + + // Generate base lowercase text using only lowercase keys from scope + let lowercase_keys: Vec = all_keys.iter() + .copied() + .filter(|ch| ch.is_ascii_lowercase() || *ch == ' ') + .collect(); + let filter = CharFilter::new(lowercase_keys); + // Only pass focused to phonetic generator if it's a lowercase letter + let lowercase_focused = focused.filter(|ch| ch.is_ascii_lowercase()); let table = self.transition_table.clone(); let dict = Dictionary::load(); let rng = SmallRng::from_rng(&mut self.rng).unwrap(); let mut generator = PhoneticGenerator::new(table, dict, rng); - generator.generate(&filter, focused, word_count) + let mut text = generator.generate(&filter, lowercase_focused, word_count); + + // Apply capitalization if uppercase keys are in scope + let cap_keys: Vec = all_keys.iter() + .copied() + .filter(|ch| ch.is_ascii_uppercase()) + .collect(); + if !cap_keys.is_empty() { + let mut rng = SmallRng::from_rng(&mut self.rng).unwrap(); + text = capitalize::apply_capitalization(&text, &cap_keys, focused, &mut rng); + } + + // Apply punctuation if punctuation keys are in scope + let punct_keys: Vec = all_keys.iter() + .copied() + .filter(|ch| matches!(ch, '.' | ',' | '\'' | ';' | ':' | '"' | '-' | '?' | '!' | '(' | ')')) + .collect(); + if !punct_keys.is_empty() { + let mut rng = SmallRng::from_rng(&mut self.rng).unwrap(); + text = punctuate::apply_punctuation(&text, &punct_keys, focused, &mut rng); + } + + // Apply numbers if digit keys are in scope + let digit_keys: Vec = all_keys.iter() + .copied() + .filter(|ch| ch.is_ascii_digit()) + .collect(); + if !digit_keys.is_empty() { + let has_dot = all_keys.contains(&'.'); + let mut rng = SmallRng::from_rng(&mut self.rng).unwrap(); + text = numbers::apply_numbers(&text, &digit_keys, has_dot, focused, &mut rng); + } + + // Apply code symbols only if this drill is for the CodeSymbols branch, + // or if it's a global drill and CodeSymbols is active + let code_active = match scope { + DrillScope::Branch(id) => id == BranchId::CodeSymbols, + DrillScope::Global => matches!( + self.skill_tree.branch_status(BranchId::CodeSymbols), + BranchStatus::InProgress | BranchStatus::Complete + ), + }; + if code_active { + let symbol_keys: Vec = all_keys.iter() + .copied() + .filter(|ch| matches!(ch, + '=' | '+' | '*' | '/' | '-' | '{' | '}' | '[' | ']' | '<' | '>' | + '&' | '|' | '^' | '~' | '@' | '#' | '$' | '%' | '_' | '\\' | '`' + )) + .collect(); + if !symbol_keys.is_empty() { + let mut rng = SmallRng::from_rng(&mut self.rng).unwrap(); + text = code_patterns::apply_code_symbols(&text, &symbol_keys, focused, &mut rng); + } + } + + // Apply whitespace line breaks if newline is in scope + if all_keys.contains(&'\n') { + text = insert_line_breaks(&text); + } + + text } DrillMode::Code => { let filter = CharFilter::new(('a'..='z').collect()); @@ -204,22 +297,23 @@ impl App { fn finish_drill(&mut self) { if let Some(ref drill) = self.drill { - let result = DrillResult::from_drill(drill, &self.drill_events, self.drill_mode.as_str()); + let ranked = self.drill_mode.is_ranked(); + let result = DrillResult::from_drill(drill, &self.drill_events, self.drill_mode.as_str(), ranked); - if self.drill_mode == DrillMode::Adaptive { + if ranked { for kt in &result.per_key_times { if kt.correct { self.key_stats.update_key(kt.key, kt.time_ms); } } - self.letter_unlock.update(&self.key_stats); + self.skill_tree.update(&self.key_stats); } - let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count()); + let complexity = self.skill_tree.complexity(); let score = scoring::compute_score(&result, complexity); self.profile.total_score += score; self.profile.total_drills += 1; - self.profile.unlocked_letters = self.letter_unlock.included.clone(); + self.profile.skill_tree = self.skill_tree.progress.clone(); let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); if self.profile.last_practice_date.as_deref() != Some(&today) { @@ -262,11 +356,11 @@ impl App { if let Some(ref store) = self.store { let _ = store.save_profile(&self.profile); let _ = store.save_key_stats(&KeyStatsData { - schema_version: 1, + schema_version: 2, stats: self.key_stats.clone(), }); let _ = store.save_drill_history(&DrillHistoryData { - schema_version: 1, + schema_version: 2, drills: self.drill_history.clone(), }); } @@ -312,27 +406,27 @@ impl App { // Reset all derived state self.key_stats = KeyStatsStore::default(); self.key_stats.target_cpm = self.config.target_cpm(); - self.letter_unlock = LetterUnlock::new(); + self.skill_tree = SkillTree::default(); self.profile.total_score = 0.0; self.profile.total_drills = 0; self.profile.streak_days = 0; self.profile.best_streak = 0; self.profile.last_practice_date = None; - // Replay each remaining session oldest→newest + // Replay each remaining session oldest->newest for result in &self.drill_history { - // Only update adaptive progression for adaptive sessions - if result.drill_mode == "adaptive" { + // Only update skill tree for ranked sessions + if result.ranked { for kt in &result.per_key_times { if kt.correct { self.key_stats.update_key(kt.key, kt.time_ms); } } - self.letter_unlock.update(&self.key_stats); + self.skill_tree.update(&self.key_stats); } // Compute score - let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count()); + let complexity = self.skill_tree.complexity(); let score = scoring::compute_score(result, complexity); self.profile.total_score += score; self.profile.total_drills += 1; @@ -359,7 +453,24 @@ impl App { } } - self.profile.unlocked_letters = self.letter_unlock.included.clone(); + self.profile.skill_tree = self.skill_tree.progress.clone(); + } + + pub fn go_to_skill_tree(&mut self) { + self.skill_tree_selected = 0; + self.screen = AppScreen::SkillTree; + } + + pub fn start_branch_drill(&mut self, branch_id: BranchId) { + // Start the branch if it's Available + self.skill_tree.start_branch(branch_id); + self.profile.skill_tree = self.skill_tree.progress.clone(); + self.save_data(); + + // Use adaptive mode with branch-specific scope + self.drill_mode = DrillMode::Adaptive; + self.drill_scope = DrillScope::Branch(branch_id); + self.start_drill(); } pub fn go_to_settings(&mut self) { @@ -445,3 +556,33 @@ impl App { } } } + +/// Insert newlines at sentence boundaries (~60-80 chars per line). +fn insert_line_breaks(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut col = 0; + + for (i, ch) in text.chars().enumerate() { + result.push(ch); + col += 1; + + // After sentence-ending punctuation + space, insert newline if past 60 chars + if col >= 60 && (ch == '.' || ch == '?' || ch == '!') { + // Check if next char is a space + let next = text.chars().nth(i + 1); + if next == Some(' ') { + result.push('\n'); + col = 0; + // Skip the space (will be consumed by next iteration but we already broke the line) + } + } else if col >= 80 && ch == ' ' { + // Hard wrap at spaces if no sentence boundary found + // Replace the space we just pushed with a newline + result.pop(); + result.push('\n'); + col = 0; + } + } + + result +} diff --git a/src/engine/letter_unlock.rs b/src/engine/letter_unlock.rs deleted file mode 100644 index 2e7299d..0000000 --- a/src/engine/letter_unlock.rs +++ /dev/null @@ -1,151 +0,0 @@ -use crate::engine::key_stats::KeyStatsStore; - -pub const FREQUENCY_ORDER: &[char] = &[ - 'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y', - 'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z', -]; - -const MIN_LETTERS: usize = 6; - -#[derive(Clone, Debug)] -pub struct LetterUnlock { - pub included: Vec, - pub focused: Option, -} - -impl LetterUnlock { - pub fn new() -> Self { - let included = FREQUENCY_ORDER[..MIN_LETTERS].to_vec(); - Self { - included, - focused: None, - } - } - - pub fn from_included(included: Vec) -> Self { - let mut lu = Self { - included, - focused: None, - }; - lu.focused = None; - lu - } - - pub fn update(&mut self, stats: &KeyStatsStore) { - let all_confident = self - .included - .iter() - .all(|&ch| stats.get_confidence(ch) >= 1.0); - - if all_confident { - for &letter in FREQUENCY_ORDER { - if !self.included.contains(&letter) { - self.included.push(letter); - break; - } - } - } - - while self.included.len() < MIN_LETTERS { - for &letter in FREQUENCY_ORDER { - if !self.included.contains(&letter) { - self.included.push(letter); - break; - } - } - } - - self.focused = self - .included - .iter() - .filter(|&&ch| stats.get_confidence(ch) < 1.0) - .min_by(|&&a, &&b| { - stats - .get_confidence(a) - .partial_cmp(&stats.get_confidence(b)) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .copied(); - } - - #[allow(dead_code)] - pub fn is_unlocked(&self, ch: char) -> bool { - self.included.contains(&ch) - } - - pub fn unlocked_count(&self) -> usize { - self.included.len() - } - - pub fn total_letters(&self) -> usize { - FREQUENCY_ORDER.len() - } - - pub fn progress(&self) -> f64 { - self.unlocked_count() as f64 / self.total_letters() as f64 - } -} - -impl Default for LetterUnlock { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::engine::key_stats::KeyStatsStore; - - #[test] - fn test_initial_unlock_has_min_letters() { - let lu = LetterUnlock::new(); - assert_eq!(lu.unlocked_count(), 6); - assert_eq!(&lu.included, &['e', 't', 'a', 'o', 'i', 'n']); - } - - #[test] - fn test_no_unlock_without_confidence() { - let mut lu = LetterUnlock::new(); - let stats = KeyStatsStore::default(); - lu.update(&stats); - assert_eq!(lu.unlocked_count(), 6); - } - - #[test] - fn test_unlock_when_all_confident() { - let mut lu = LetterUnlock::new(); - let mut stats = KeyStatsStore::default(); - // Make all included keys confident by typing fast - for &ch in &['e', 't', 'a', 'o', 'i', 'n'] { - for _ in 0..50 { - stats.update_key(ch, 200.0); - } - } - lu.update(&stats); - assert_eq!(lu.unlocked_count(), 7); - assert!(lu.included.contains(&'s')); - } - - #[test] - fn test_focused_key_is_weakest() { - let mut lu = LetterUnlock::new(); - let mut stats = KeyStatsStore::default(); - // Make most keys confident except 'o' - for &ch in &['e', 't', 'a', 'i', 'n'] { - for _ in 0..50 { - stats.update_key(ch, 200.0); - } - } - stats.update_key('o', 1000.0); // slow on 'o' - lu.update(&stats); - assert_eq!(lu.focused, Some('o')); - } - - #[test] - fn test_progress_ratio() { - let lu = LetterUnlock::new(); - let expected = 6.0 / 26.0; - assert!((lu.progress() - expected).abs() < 0.001); - } -} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 7e696f9..19a62fd 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1,5 +1,5 @@ pub mod filter; pub mod key_stats; pub mod learning_rate; -pub mod letter_unlock; pub mod scoring; +pub mod skill_tree; diff --git a/src/engine/scoring.rs b/src/engine/scoring.rs index 7e47280..920bbde 100644 --- a/src/engine/scoring.rs +++ b/src/engine/scoring.rs @@ -7,8 +7,8 @@ pub fn compute_score(result: &DrillResult, complexity: f64) -> f64 { (speed * complexity) / (errors + 1.0) * (length / 50.0) } -pub fn compute_complexity(unlocked_count: usize) -> f64 { - (unlocked_count as f64 / 26.0).max(0.1) +pub fn compute_complexity(unlocked_count: usize, total_keys: usize) -> f64 { + (unlocked_count as f64 / total_keys as f64).max(0.1) } pub fn level_from_score(total_score: f64) -> u32 { @@ -38,8 +38,8 @@ mod tests { } #[test] - fn test_complexity_scales_with_letters() { - assert!(compute_complexity(26) > compute_complexity(6)); - assert!((compute_complexity(26) - 1.0).abs() < 0.001); + fn test_complexity_scales_with_keys() { + assert!(compute_complexity(96, 96) > compute_complexity(6, 96)); + assert!((compute_complexity(96, 96) - 1.0).abs() < 0.001); } } diff --git a/src/engine/skill_tree.rs b/src/engine/skill_tree.rs new file mode 100644 index 0000000..b452416 --- /dev/null +++ b/src/engine/skill_tree.rs @@ -0,0 +1,854 @@ +use std::collections::{HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use crate::engine::key_stats::KeyStatsStore; + +// --- Branch ID --- + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum BranchId { + Lowercase, + Capitals, + Numbers, + ProsePunctuation, + Whitespace, + CodeSymbols, +} + +impl BranchId { + pub fn to_key(self) -> &'static str { + match self { + BranchId::Lowercase => "lowercase", + BranchId::Capitals => "capitals", + BranchId::Numbers => "numbers", + BranchId::ProsePunctuation => "prose_punctuation", + BranchId::Whitespace => "whitespace", + BranchId::CodeSymbols => "code_symbols", + } + } + + pub fn from_key(key: &str) -> Option { + match key { + "lowercase" => Some(BranchId::Lowercase), + "capitals" => Some(BranchId::Capitals), + "numbers" => Some(BranchId::Numbers), + "prose_punctuation" => Some(BranchId::ProsePunctuation), + "whitespace" => Some(BranchId::Whitespace), + "code_symbols" => Some(BranchId::CodeSymbols), + _ => None, + } + } + + pub fn all() -> &'static [BranchId] { + &[ + BranchId::Lowercase, + BranchId::Capitals, + BranchId::Numbers, + BranchId::ProsePunctuation, + BranchId::Whitespace, + BranchId::CodeSymbols, + ] + } +} + +// --- Branch Status --- + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum BranchStatus { + Locked, + Available, + InProgress, + Complete, +} + +// --- Static Definitions --- + +pub struct LevelDefinition { + pub name: &'static str, + pub keys: &'static [char], +} + +pub struct BranchDefinition { + pub id: BranchId, + pub name: &'static str, + pub levels: &'static [LevelDefinition], +} + +const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition { + name: "Frequency Order", + keys: &[ + 'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', + 'y', 'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z', + ], +}]; + +const CAPITALS_LEVELS: &[LevelDefinition] = &[ + LevelDefinition { + name: "Common Sentence Capitals", + keys: &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'], + }, + LevelDefinition { + name: "Name Capitals", + keys: &['J', 'D', 'R', 'C', 'E', 'N', 'P', 'L', 'F', 'G'], + }, + LevelDefinition { + name: "Remaining Capitals", + keys: &['O', 'U', 'K', 'V', 'Y', 'X', 'Q', 'Z'], + }, +]; + +const NUMBERS_LEVELS: &[LevelDefinition] = &[ + LevelDefinition { + name: "Common Digits", + keys: &['1', '2', '3', '4', '5'], + }, + LevelDefinition { + name: "All Digits", + keys: &['0', '6', '7', '8', '9'], + }, +]; + +const PROSE_PUNCTUATION_LEVELS: &[LevelDefinition] = &[ + LevelDefinition { + name: "Essential", + keys: &['.', ',', '\''], + }, + LevelDefinition { + name: "Common", + keys: &[';', ':', '"', '-'], + }, + LevelDefinition { + name: "Expressive", + keys: &['?', '!', '(', ')'], + }, +]; + +const WHITESPACE_LEVELS: &[LevelDefinition] = &[ + LevelDefinition { + name: "Enter/Return", + keys: &['\n'], + }, + LevelDefinition { + name: "Tab/Indent", + keys: &['\t'], + }, +]; + +const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[ + LevelDefinition { + name: "Arithmetic & Assignment", + keys: &['=', '+', '*', '/', '-'], + }, + LevelDefinition { + name: "Grouping", + keys: &['{', '}', '[', ']', '<', '>'], + }, + LevelDefinition { + name: "Logic & Reference", + keys: &['&', '|', '^', '~', '!'], + }, + LevelDefinition { + name: "Special", + keys: &['@', '#', '$', '%', '_', '\\', '`'], + }, +]; + +pub const ALL_BRANCHES: &[BranchDefinition] = &[ + BranchDefinition { + id: BranchId::Lowercase, + name: "Lowercase a-z", + levels: LOWERCASE_LEVELS, + }, + BranchDefinition { + id: BranchId::Capitals, + name: "Capitals A-Z", + levels: CAPITALS_LEVELS, + }, + BranchDefinition { + id: BranchId::Numbers, + name: "Numbers 0-9", + levels: NUMBERS_LEVELS, + }, + BranchDefinition { + id: BranchId::ProsePunctuation, + name: "Prose Punctuation", + levels: PROSE_PUNCTUATION_LEVELS, + }, + BranchDefinition { + id: BranchId::Whitespace, + name: "Whitespace", + levels: WHITESPACE_LEVELS, + }, + BranchDefinition { + id: BranchId::CodeSymbols, + name: "Code Symbols", + levels: CODE_SYMBOLS_LEVELS, + }, +]; + +pub fn get_branch_definition(id: BranchId) -> &'static BranchDefinition { + ALL_BRANCHES + .iter() + .find(|b| b.id == id) + .expect("branch definition not found") +} + +// --- Persisted Progress --- + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BranchProgress { + pub status: BranchStatus, + pub current_level: usize, +} + +impl Default for BranchProgress { + fn default() -> Self { + Self { + status: BranchStatus::Locked, + current_level: 0, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SkillTreeProgress { + pub branches: HashMap, +} + +impl Default for SkillTreeProgress { + fn default() -> Self { + let mut branches = HashMap::new(); + // Lowercase starts as InProgress; everything else Locked + branches.insert( + BranchId::Lowercase.to_key().to_string(), + BranchProgress { + status: BranchStatus::InProgress, + current_level: 0, + }, + ); + for &id in &[ + BranchId::Capitals, + BranchId::Numbers, + BranchId::ProsePunctuation, + BranchId::Whitespace, + BranchId::CodeSymbols, + ] { + branches.insert(id.to_key().to_string(), BranchProgress::default()); + } + Self { branches } + } +} + +// --- Skill Tree Engine --- + +/// The scope for key collection and focus selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DrillScope { + /// Global adaptive: all InProgress + Complete branches + Global, + /// Branch-specific drill: specific branch + a-z background + Branch(BranchId), +} + +pub struct SkillTree { + pub progress: SkillTreeProgress, + pub total_unique_keys: usize, +} + +/// Number of lowercase letters to start with before unlocking one-at-a-time +const LOWERCASE_MIN_KEYS: usize = 6; + +impl SkillTree { + pub fn new(progress: SkillTreeProgress) -> Self { + let total_unique_keys = Self::compute_total_unique_keys(); + Self { + progress, + total_unique_keys, + } + } + + fn compute_total_unique_keys() -> usize { + let mut all_keys: HashSet = HashSet::new(); + for branch in ALL_BRANCHES { + for level in branch.levels { + for &key in level.keys { + all_keys.insert(key); + } + } + } + all_keys.len() + } + + pub fn branch_status(&self, id: BranchId) -> &BranchStatus { + self.progress + .branches + .get(id.to_key()) + .map(|bp| &bp.status) + .unwrap_or(&BranchStatus::Locked) + } + + pub fn branch_progress(&self, id: BranchId) -> &BranchProgress { + static DEFAULT: BranchProgress = BranchProgress { + status: BranchStatus::Locked, + current_level: 0, + }; + self.progress + .branches + .get(id.to_key()) + .unwrap_or(&DEFAULT) + } + + pub fn branch_progress_mut(&mut self, id: BranchId) -> &mut BranchProgress { + self.progress + .branches + .entry(id.to_key().to_string()) + .or_default() + } + + /// Start a branch (transition Available -> InProgress). + pub fn start_branch(&mut self, id: BranchId) { + let bp = self.branch_progress_mut(id); + if bp.status == BranchStatus::Available { + bp.status = BranchStatus::InProgress; + bp.current_level = 0; + } + } + + /// Collect all unlocked keys for the given scope. + pub fn unlocked_keys(&self, scope: DrillScope) -> Vec { + match scope { + DrillScope::Global => self.global_unlocked_keys(), + DrillScope::Branch(id) => self.branch_unlocked_keys(id), + } + } + + fn global_unlocked_keys(&self) -> Vec { + let mut keys = Vec::new(); + for branch_def in ALL_BRANCHES { + let bp = self.branch_progress(branch_def.id); + match bp.status { + BranchStatus::InProgress => { + // For lowercase, use the progressive unlock system + if branch_def.id == BranchId::Lowercase { + keys.extend(self.lowercase_unlocked_keys()); + } else { + // Include current level's keys + all prior levels + for (i, level) in branch_def.levels.iter().enumerate() { + if i <= bp.current_level { + keys.extend_from_slice(level.keys); + } + } + } + } + BranchStatus::Complete => { + for level in branch_def.levels { + keys.extend_from_slice(level.keys); + } + } + _ => {} + } + } + keys + } + + fn branch_unlocked_keys(&self, id: BranchId) -> Vec { + let mut keys = Vec::new(); + + // Always include a-z background keys + if id != BranchId::Lowercase { + let lowercase_def = get_branch_definition(BranchId::Lowercase); + let lowercase_bp = self.branch_progress(BranchId::Lowercase); + match lowercase_bp.status { + BranchStatus::InProgress => keys.extend(self.lowercase_unlocked_keys()), + BranchStatus::Complete => { + for level in lowercase_def.levels { + keys.extend_from_slice(level.keys); + } + } + _ => {} + } + } + + // Include keys from the target branch + let branch_def = get_branch_definition(id); + let bp = self.branch_progress(id); + if id == BranchId::Lowercase { + keys.extend(self.lowercase_unlocked_keys()); + } else { + match bp.status { + BranchStatus::InProgress => { + for (i, level) in branch_def.levels.iter().enumerate() { + if i <= bp.current_level { + keys.extend_from_slice(level.keys); + } + } + } + BranchStatus::Complete => { + for level in branch_def.levels { + keys.extend_from_slice(level.keys); + } + } + _ => {} + } + } + + keys + } + + /// Get the progressively-unlocked lowercase keys (mirrors old LetterUnlock logic). + fn lowercase_unlocked_keys(&self) -> Vec { + let def = get_branch_definition(BranchId::Lowercase); + let bp = self.branch_progress(BranchId::Lowercase); + let all_keys = def.levels[0].keys; + + match bp.status { + BranchStatus::Complete => all_keys.to_vec(), + BranchStatus::InProgress => { + // current_level represents number of keys unlocked beyond LOWERCASE_MIN_KEYS + let count = (LOWERCASE_MIN_KEYS + bp.current_level).min(all_keys.len()); + all_keys[..count].to_vec() + } + _ => Vec::new(), + } + } + + /// Number of unlocked lowercase letters (for display). + pub fn lowercase_unlocked_count(&self) -> usize { + self.lowercase_unlocked_keys().len() + } + + /// Find the focused (weakest) key for the given scope. + pub fn focused_key(&self, scope: DrillScope, stats: &KeyStatsStore) -> Option { + match scope { + DrillScope::Global => self.global_focused_key(stats), + DrillScope::Branch(id) => self.branch_focused_key(id, stats), + } + } + + fn global_focused_key(&self, stats: &KeyStatsStore) -> Option { + // Collect keys from all InProgress branches (current level only) + complete branches + let mut focus_candidates = Vec::new(); + for branch_def in ALL_BRANCHES { + let bp = self.branch_progress(branch_def.id); + match bp.status { + BranchStatus::InProgress => { + if branch_def.id == BranchId::Lowercase { + focus_candidates.extend(self.lowercase_unlocked_keys()); + } else if bp.current_level < branch_def.levels.len() { + // Only current level keys are focus candidates + focus_candidates + .extend_from_slice(branch_def.levels[bp.current_level].keys); + // Plus prior level keys for reinforcement + for i in 0..bp.current_level { + focus_candidates.extend_from_slice(branch_def.levels[i].keys); + } + } + } + BranchStatus::Complete => { + for level in branch_def.levels { + focus_candidates.extend_from_slice(level.keys); + } + } + _ => {} + } + } + + Self::weakest_key(&focus_candidates, stats) + } + + fn branch_focused_key(&self, id: BranchId, stats: &KeyStatsStore) -> Option { + let branch_def = get_branch_definition(id); + let bp = self.branch_progress(id); + + if id == BranchId::Lowercase { + return Self::weakest_key(&self.lowercase_unlocked_keys(), stats); + } + + match bp.status { + BranchStatus::InProgress if bp.current_level < branch_def.levels.len() => { + // Focus only within current level's keys + let current_keys = branch_def.levels[bp.current_level].keys; + Self::weakest_key(¤t_keys.to_vec(), stats) + } + _ => None, + } + } + + fn weakest_key(keys: &[char], stats: &KeyStatsStore) -> Option { + keys.iter() + .filter(|&&ch| stats.get_confidence(ch) < 1.0) + .min_by(|&&a, &&b| { + stats + .get_confidence(a) + .partial_cmp(&stats.get_confidence(b)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + } + + /// Update skill tree progress based on current key stats. + /// Call after updating KeyStatsStore. + pub fn update(&mut self, stats: &KeyStatsStore) { + // Update lowercase branch (progressive unlock) + self.update_lowercase(stats); + + // Check if lowercase is complete -> unlock other branches + if *self.branch_status(BranchId::Lowercase) == BranchStatus::Complete { + for &id in &[ + BranchId::Capitals, + BranchId::Numbers, + BranchId::ProsePunctuation, + BranchId::Whitespace, + BranchId::CodeSymbols, + ] { + let bp = self.branch_progress_mut(id); + if bp.status == BranchStatus::Locked { + bp.status = BranchStatus::Available; + } + } + } + + // Update InProgress branches (non-lowercase) + for branch_def in ALL_BRANCHES { + if branch_def.id == BranchId::Lowercase { + continue; + } + let bp = self.branch_progress(branch_def.id).clone(); + if bp.status != BranchStatus::InProgress { + continue; + } + self.update_branch_level(branch_def, stats); + } + } + + fn update_lowercase(&mut self, stats: &KeyStatsStore) { + let bp = self.branch_progress(BranchId::Lowercase).clone(); + if bp.status != BranchStatus::InProgress { + return; + } + + let all_keys = get_branch_definition(BranchId::Lowercase).levels[0].keys; + let current_count = LOWERCASE_MIN_KEYS + bp.current_level; + + if current_count >= all_keys.len() { + // All 26 keys unlocked, check if all confident + let all_confident = all_keys.iter().all(|&ch| stats.get_confidence(ch) >= 1.0); + if all_confident { + let bp_mut = self.branch_progress_mut(BranchId::Lowercase); + bp_mut.status = BranchStatus::Complete; + bp_mut.current_level = all_keys.len() - LOWERCASE_MIN_KEYS; + } + return; + } + + // Check if all current keys are confident -> unlock next + let current_keys = &all_keys[..current_count]; + let all_confident = current_keys + .iter() + .all(|&ch| stats.get_confidence(ch) >= 1.0); + + if all_confident { + let bp_mut = self.branch_progress_mut(BranchId::Lowercase); + bp_mut.current_level += 1; + } + } + + fn update_branch_level(&mut self, branch_def: &BranchDefinition, stats: &KeyStatsStore) { + let bp = self.branch_progress(branch_def.id).clone(); + if bp.current_level >= branch_def.levels.len() { + // Already past last level, mark complete + let bp_mut = self.branch_progress_mut(branch_def.id); + bp_mut.status = BranchStatus::Complete; + return; + } + + // Check if all keys in current level are confident + let current_level_keys = branch_def.levels[bp.current_level].keys; + let all_confident = current_level_keys + .iter() + .all(|&ch| stats.get_confidence(ch) >= 1.0); + + if all_confident { + let bp_mut = self.branch_progress_mut(branch_def.id); + bp_mut.current_level += 1; + if bp_mut.current_level >= branch_def.levels.len() { + bp_mut.status = BranchStatus::Complete; + } + } + } + + /// Total number of unlocked unique keys across all branches. + pub fn total_unlocked_count(&self) -> usize { + let mut keys: HashSet = HashSet::new(); + for branch_def in ALL_BRANCHES { + let bp = self.branch_progress(branch_def.id); + match bp.status { + BranchStatus::InProgress => { + if branch_def.id == BranchId::Lowercase { + for key in self.lowercase_unlocked_keys() { + keys.insert(key); + } + } else { + for (i, level) in branch_def.levels.iter().enumerate() { + if i <= bp.current_level { + for &key in level.keys { + keys.insert(key); + } + } + } + } + } + BranchStatus::Complete => { + for level in branch_def.levels { + for &key in level.keys { + keys.insert(key); + } + } + } + _ => {} + } + } + keys.len() + } + + /// Complexity for scoring: total_unlocked / total_unique + pub fn complexity(&self) -> f64 { + (self.total_unlocked_count() as f64 / self.total_unique_keys as f64).max(0.1) + } + + /// Get all branch definitions with their current progress (for UI). + pub fn all_branches_with_progress(&self) -> Vec<(&'static BranchDefinition, &BranchProgress)> { + ALL_BRANCHES + .iter() + .map(|def| (def, self.branch_progress(def.id))) + .collect() + } + + /// Total keys defined in a branch (across all levels). + pub fn branch_total_keys(id: BranchId) -> usize { + let def = get_branch_definition(id); + def.levels.iter().map(|l| l.keys.len()).sum() + } + + /// Count of confident keys in a branch. + pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize { + let def = get_branch_definition(id); + def.levels + .iter() + .flat_map(|l| l.keys.iter()) + .filter(|&&ch| stats.get_confidence(ch) >= 1.0) + .count() + } +} + +impl Default for SkillTree { + fn default() -> Self { + Self::new(SkillTreeProgress::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_stats_confident(stats: &mut KeyStatsStore, keys: &[char]) { + for &ch in keys { + for _ in 0..50 { + stats.update_key(ch, 200.0); + } + } + } + + #[test] + fn test_initial_state() { + let tree = SkillTree::default(); + assert_eq!(*tree.branch_status(BranchId::Lowercase), BranchStatus::InProgress); + assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::Locked); + assert_eq!(*tree.branch_status(BranchId::Numbers), BranchStatus::Locked); + } + + #[test] + fn test_total_unique_keys() { + let tree = SkillTree::default(); + assert_eq!(tree.total_unique_keys, 96); + } + + #[test] + fn test_initial_lowercase_unlocked() { + let tree = SkillTree::default(); + let keys = tree.unlocked_keys(DrillScope::Global); + assert_eq!(keys.len(), LOWERCASE_MIN_KEYS); + assert_eq!(&keys[..6], &['e', 't', 'a', 'o', 'i', 'n']); + } + + #[test] + fn test_lowercase_progressive_unlock() { + let mut tree = SkillTree::default(); + let mut stats = KeyStatsStore::default(); + + // Make initial 6 keys confident + make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']); + tree.update(&stats); + + // Should unlock 7th key ('s') + let keys = tree.unlocked_keys(DrillScope::Global); + assert_eq!(keys.len(), 7); + assert!(keys.contains(&'s')); + } + + #[test] + fn test_lowercase_completion_unlocks_branches() { + let mut tree = SkillTree::default(); + let mut stats = KeyStatsStore::default(); + + // Make all 26 lowercase keys confident + let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys; + make_stats_confident(&mut stats, all_lowercase); + + // Need to repeatedly update as each unlock requires all current keys confident + for _ in 0..30 { + tree.update(&stats); + } + + assert_eq!(*tree.branch_status(BranchId::Lowercase), BranchStatus::Complete); + assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::Available); + assert_eq!(*tree.branch_status(BranchId::Numbers), BranchStatus::Available); + assert_eq!(*tree.branch_status(BranchId::ProsePunctuation), BranchStatus::Available); + assert_eq!(*tree.branch_status(BranchId::Whitespace), BranchStatus::Available); + assert_eq!(*tree.branch_status(BranchId::CodeSymbols), BranchStatus::Available); + } + + #[test] + fn test_start_branch() { + let mut tree = SkillTree::default(); + // Force capitals to Available + tree.branch_progress_mut(BranchId::Capitals).status = BranchStatus::Available; + + tree.start_branch(BranchId::Capitals); + assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::InProgress); + assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 0); + } + + #[test] + fn test_branch_level_advancement() { + let mut tree = SkillTree::default(); + let mut stats = KeyStatsStore::default(); + + // Set capitals to InProgress at level 0 + let bp = tree.branch_progress_mut(BranchId::Capitals); + bp.status = BranchStatus::InProgress; + bp.current_level = 0; + + // Make level 1 capitals confident: T I A S W H B M + make_stats_confident(&mut stats, &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M']); + tree.update(&stats); + + assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 1); + assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::InProgress); + } + + #[test] + fn test_branch_completion() { + let mut tree = SkillTree::default(); + let mut stats = KeyStatsStore::default(); + + let bp = tree.branch_progress_mut(BranchId::Capitals); + bp.status = BranchStatus::InProgress; + bp.current_level = 0; + + // Make all capital letter levels confident + let all_caps: Vec = ('A'..='Z').collect(); + make_stats_confident(&mut stats, &all_caps); + + // Update multiple times for level advancement + for _ in 0..5 { + tree.update(&stats); + } + + assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::Complete); + } + + #[test] + fn test_shared_key_confidence() { + let _tree = SkillTree::default(); + let mut stats = KeyStatsStore::default(); + + // '-' is shared between ProsePunctuation L2 and CodeSymbols L1 + // Master it once + make_stats_confident(&mut stats, &['-']); + + // Both branches should see it as confident + assert!(stats.get_confidence('-') >= 1.0); + } + + #[test] + fn test_focused_key_global() { + let tree = SkillTree::default(); + let stats = KeyStatsStore::default(); + + // All keys at 0 confidence, focused should be first in order + let focused = tree.focused_key(DrillScope::Global, &stats); + assert!(focused.is_some()); + // Should be one of the initial 6 lowercase keys + assert!( + ['e', 't', 'a', 'o', 'i', 'n'].contains(&focused.unwrap()), + "focused: {:?}", + focused + ); + } + + #[test] + fn test_focused_key_branch() { + let mut tree = SkillTree::default(); + let stats = KeyStatsStore::default(); + + let bp = tree.branch_progress_mut(BranchId::Capitals); + bp.status = BranchStatus::InProgress; + bp.current_level = 0; + + let focused = tree.focused_key(DrillScope::Branch(BranchId::Capitals), &stats); + assert!(focused.is_some()); + // Should be one of level 1 capitals + assert!( + ['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'].contains(&focused.unwrap()), + "focused: {:?}", + focused + ); + } + + #[test] + fn test_complexity_scales() { + let tree = SkillTree::default(); + let initial_complexity = tree.complexity(); + assert!(initial_complexity > 0.0); + assert!(initial_complexity < 1.0); + + // Full unlock should give complexity ~1.0 + let mut full_tree = SkillTree::default(); + for id in BranchId::all() { + let bp = full_tree.branch_progress_mut(*id); + bp.status = BranchStatus::Complete; + } + let full_complexity = full_tree.complexity(); + assert!((full_complexity - 1.0).abs() < 0.01); + } + + #[test] + fn test_branch_keys_for_drill() { + let mut tree = SkillTree::default(); + + // Set lowercase complete, capitals in progress at level 1 + tree.branch_progress_mut(BranchId::Lowercase).status = BranchStatus::Complete; + let bp = tree.branch_progress_mut(BranchId::Capitals); + bp.status = BranchStatus::InProgress; + bp.current_level = 1; + + let keys = tree.unlocked_keys(DrillScope::Branch(BranchId::Capitals)); + // Should include all 26 lowercase + Capitals L1 (8) + Capitals L2 (10) + assert!(keys.contains(&'e')); // lowercase background + assert!(keys.contains(&'T')); // Capitals L1 + assert!(keys.contains(&'J')); // Capitals L2 (current level) + assert!(!keys.contains(&'O')); // Capitals L3 (locked) + } +} diff --git a/src/generator/capitalize.rs b/src/generator/capitalize.rs new file mode 100644 index 0000000..df981c6 --- /dev/null +++ b/src/generator/capitalize.rs @@ -0,0 +1,123 @@ +use rand::rngs::SmallRng; +use rand::Rng; + +/// Post-processing pass that capitalizes words in generated text. +/// Only capitalizes using letters from `unlocked_capitals`. +pub fn apply_capitalization( + text: &str, + unlocked_capitals: &[char], + focused: Option, + rng: &mut SmallRng, +) -> String { + if unlocked_capitals.is_empty() { + return text.to_string(); + } + + // If focused key is an uppercase letter, boost its probability + let focused_upper = focused.filter(|ch| ch.is_ascii_uppercase()); + + let mut result = String::with_capacity(text.len()); + let mut at_sentence_start = true; + + for (i, ch) in text.chars().enumerate() { + if at_sentence_start && ch.is_ascii_lowercase() { + let upper = ch.to_ascii_uppercase(); + if unlocked_capitals.contains(&upper) { + result.push(upper); + at_sentence_start = false; + continue; + } + } + + // After period/question/exclamation + space, next word starts a sentence + if ch == ' ' && i > 0 { + let prev = text.as_bytes().get(i - 1).map(|&b| b as char); + if matches!(prev, Some('.' | '?' | '!')) { + at_sentence_start = true; + } + } + + // Capitalize word starts: boosted for focused key, ~12% for others + if ch.is_ascii_lowercase() && !at_sentence_start { + let is_word_start = i == 0 || text.as_bytes().get(i - 1).map(|&b| b as char) == Some(' '); + if is_word_start { + let upper = ch.to_ascii_uppercase(); + if unlocked_capitals.contains(&upper) { + let prob = if focused_upper == Some(upper) { 0.40 } else { 0.12 }; + if rng.gen_bool(prob) { + result.push(upper); + continue; + } + } + } + } + + if ch != '.' && ch != '?' && ch != '!' { + at_sentence_start = false; + } + + result.push(ch); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + + #[test] + fn test_no_caps_when_empty() { + let mut rng = SmallRng::seed_from_u64(42); + let result = apply_capitalization("hello world", &[], None, &mut rng); + assert_eq!(result, "hello world"); + } + + #[test] + fn test_capitalizes_first_word() { + let mut rng = SmallRng::seed_from_u64(42); + let result = apply_capitalization("hello world", &['H', 'W'], None, &mut rng); + assert!(result.starts_with('H')); + } + + #[test] + fn test_only_capitalizes_unlocked() { + let mut rng = SmallRng::seed_from_u64(42); + // Only 'W' is unlocked, not 'H' + let result = apply_capitalization("hello world", &['W'], None, &mut rng); + assert!(result.starts_with('h')); // 'H' not unlocked + } + + #[test] + fn test_after_period() { + let mut rng = SmallRng::seed_from_u64(42); + let result = apply_capitalization("one. two", &['O', 'T'], None, &mut rng); + assert!(result.starts_with('O')); + assert!(result.contains("Two") || result.contains("two")); + // At minimum, first word should be capitalized + } + + #[test] + fn test_focused_capital_boosted() { + // With focused 'W', W capitalization should happen more often + let caps = &['H', 'W']; + let mut focused_count = 0; + let mut unfocused_count = 0; + // Run many trials to check statistical boosting + for seed in 0..200 { + let mut rng = SmallRng::seed_from_u64(seed); + let text = "hello world wide web wonder what where who will work"; + let result = apply_capitalization(text, caps, Some('W'), &mut rng); + // Count W capitalizations (skip first word which is always capitalized if 'H' is available) + focused_count += result.matches('W').count(); + let mut rng2 = SmallRng::seed_from_u64(seed); + let result2 = apply_capitalization(text, caps, None, &mut rng2); + unfocused_count += result2.matches('W').count(); + } + assert!( + focused_count > unfocused_count, + "Focused W count ({focused_count}) should exceed unfocused ({unfocused_count})" + ); + } +} diff --git a/src/generator/code_patterns.rs b/src/generator/code_patterns.rs new file mode 100644 index 0000000..e6029ca --- /dev/null +++ b/src/generator/code_patterns.rs @@ -0,0 +1,220 @@ +use rand::rngs::SmallRng; +use rand::Rng; + +/// Post-processing pass that inserts code-like expressions into text. +/// Only uses symbols from `unlocked_symbols`. +pub fn apply_code_symbols( + text: &str, + unlocked_symbols: &[char], + focused: Option, + rng: &mut SmallRng, +) -> String { + if unlocked_symbols.is_empty() { + return text.to_string(); + } + + // If focused key is a code symbol, boost insertion probability + let focused_symbol = focused.filter(|ch| unlocked_symbols.contains(ch)); + let base_prob = if focused_symbol.is_some() { 0.35 } else { 0.20 }; + + let words: Vec<&str> = text.split(' ').collect(); + let mut result = Vec::new(); + + for word in &words { + if rng.gen_bool(base_prob) { + let expr = generate_code_expr(word, unlocked_symbols, focused_symbol, rng); + result.push(expr); + } else { + result.push(word.to_string()); + } + } + + result.join(" ") +} + +fn generate_code_expr( + word: &str, + symbols: &[char], + focused_symbol: Option, + rng: &mut SmallRng, +) -> String { + // Categorize available symbols + let has = |ch: char| symbols.contains(&ch); + + // Try various patterns based on available symbols + let mut patterns: Vec String>> = Vec::new(); + // Track which patterns use the focused symbol for priority selection + let mut focused_patterns: Vec = Vec::new(); + + // Arithmetic & Assignment patterns + if has('=') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("{w} = val"))); + if focused_symbol == Some('=') { focused_patterns.push(idx); } + } + if has('+') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("{w} + num"))); + if focused_symbol == Some('+') { focused_patterns.push(idx); } + } + if has('*') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("{w} * cnt"))); + if focused_symbol == Some('*') { focused_patterns.push(idx); } + } + if has('/') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("{w} / max"))); + if focused_symbol == Some('/') { focused_patterns.push(idx); } + } + if has('-') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("{w} - one"))); + if focused_symbol == Some('-') { focused_patterns.push(idx); } + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("-{w}"))); + if focused_symbol == Some('-') { focused_patterns.push(idx); } + } + if has('=') && has('+') { + let w = word.to_string(); + patterns.push(Box::new(move |_| format!("{w} += one"))); + } + if has('=') && has('-') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("{w} -= one"))); + if focused_symbol == Some('-') { focused_patterns.push(idx); } + } + if has('=') && has('=') { + let w = word.to_string(); + patterns.push(Box::new(move |_| format!("{w} == nil"))); + } + + // Grouping patterns + if has('{') && has('}') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("{{ {w} }}"))); + if matches!(focused_symbol, Some('{') | Some('}')) { focused_patterns.push(idx); } + } + if has('[') && has(']') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("{w}[idx]"))); + if matches!(focused_symbol, Some('[') | Some(']')) { focused_patterns.push(idx); } + } + if has('<') && has('>') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("Vec<{w}>"))); + if matches!(focused_symbol, Some('<') | Some('>')) { focused_patterns.push(idx); } + } + + // Logic patterns + if has('&') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("&{w}"))); + if focused_symbol == Some('&') { focused_patterns.push(idx); } + } + if has('|') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("{w} | nil"))); + if focused_symbol == Some('|') { focused_patterns.push(idx); } + } + if has('!') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("!{w}"))); + if focused_symbol == Some('!') { focused_patterns.push(idx); } + } + + // Special patterns + if has('@') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("@{w}"))); + if focused_symbol == Some('@') { focused_patterns.push(idx); } + } + if has('#') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("#{w}"))); + if focused_symbol == Some('#') { focused_patterns.push(idx); } + } + if has('_') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("{w}_val"))); + if focused_symbol == Some('_') { focused_patterns.push(idx); } + } + if has('$') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("${w}"))); + if focused_symbol == Some('$') { focused_patterns.push(idx); } + } + if has('\\') { + let w = word.to_string(); + let idx = patterns.len(); + patterns.push(Box::new(move |_| format!("\\{w}"))); + if focused_symbol == Some('\\') { focused_patterns.push(idx); } + } + + if patterns.is_empty() { + return word.to_string(); + } + + // 50% chance to prefer a pattern that uses the focused symbol + let idx = if !focused_patterns.is_empty() && rng.gen_bool(0.50) { + focused_patterns[rng.gen_range(0..focused_patterns.len())] + } else { + rng.gen_range(0..patterns.len()) + }; + patterns[idx](rng) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + + #[test] + fn test_no_symbols_when_empty() { + let mut rng = SmallRng::seed_from_u64(42); + let result = apply_code_symbols("hello world", &[], None, &mut rng); + assert_eq!(result, "hello world"); + } + + #[test] + fn test_uses_only_unlocked_symbols() { + let mut rng = SmallRng::seed_from_u64(42); + let symbols = ['=', '+']; + let text = "a b c d e f g h i j"; + let result = apply_code_symbols(text, &symbols, None, &mut rng); + for ch in result.chars() { + if !ch.is_alphanumeric() && ch != ' ' { + assert!( + symbols.contains(&ch), + "Unexpected symbol '{ch}' in: {result}" + ); + } + } + } + + #[test] + fn test_dash_patterns_generated() { + let mut rng = SmallRng::seed_from_u64(42); + let symbols = ['-', '=']; + let text = "a b c d e f g h i j k l m n o p q r s t"; + let result = apply_code_symbols(text, &symbols, None, &mut rng); + assert!(result.contains('-'), "Expected dash in: {result}"); + } +} diff --git a/src/generator/code_syntax.rs b/src/generator/code_syntax.rs index 6b75d79..66c0204 100644 --- a/src/generator/code_syntax.rs +++ b/src/generator/code_syntax.rs @@ -245,11 +245,11 @@ impl TextGenerator for CodeSyntaxGenerator { result.push(snippet.to_string()); } - result.join(" ") + result.join("\n\n") } } -/// Extract function-length snippets from raw source code +/// Extract function-length snippets from raw source code, preserving whitespace. fn extract_code_snippets(source: &str) -> Vec { let mut snippets = Vec::new(); let lines: Vec<&str> = source.lines().collect(); @@ -285,11 +285,11 @@ fn extract_code_snippets(source: &str) -> Vec { } if snippet_lines.len() >= 3 && snippet_lines.len() <= 30 { - let snippet = snippet_lines.join(" "); - // Normalize whitespace - let normalized: String = snippet.split_whitespace().collect::>().join(" "); - if normalized.len() >= 20 && normalized.len() <= 500 { - snippets.push(normalized); + // Preserve original newlines and indentation + let snippet = snippet_lines.join("\n"); + let char_count = snippet.chars().filter(|c| !c.is_whitespace()).count(); + if char_count >= 20 && snippet.len() <= 800 { + snippets.push(snippet); } } diff --git a/src/generator/mod.rs b/src/generator/mod.rs index 49d8e7a..d155b48 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -1,9 +1,13 @@ pub mod cache; +pub mod capitalize; +pub mod code_patterns; pub mod code_syntax; pub mod dictionary; pub mod github_code; +pub mod numbers; pub mod passage; pub mod phonetic; +pub mod punctuate; pub mod transition_table; use crate::engine::filter::CharFilter; diff --git a/src/generator/numbers.rs b/src/generator/numbers.rs new file mode 100644 index 0000000..4f02b5f --- /dev/null +++ b/src/generator/numbers.rs @@ -0,0 +1,132 @@ +use rand::rngs::SmallRng; +use rand::Rng; + +/// Post-processing pass that inserts number expressions into text. +/// Only uses digits from `unlocked_digits`. +pub fn apply_numbers( + text: &str, + unlocked_digits: &[char], + has_dot: bool, + focused: Option, + rng: &mut SmallRng, +) -> String { + if unlocked_digits.is_empty() { + return text.to_string(); + } + + // If focused key is a digit, boost number insertion probability + let focused_digit = focused.filter(|ch| ch.is_ascii_digit()); + let base_prob = if focused_digit.is_some() { 0.30 } else { 0.15 }; + + let words: Vec<&str> = text.split(' ').collect(); + let mut result = Vec::new(); + + for word in &words { + if rng.gen_bool(base_prob) { + let expr = generate_number_expr(unlocked_digits, has_dot, focused_digit, rng); + result.push(expr); + } else { + result.push(word.to_string()); + } + } + + result.join(" ") +} + +fn generate_number_expr( + digits: &[char], + has_dot: bool, + focused_digit: Option, + rng: &mut SmallRng, +) -> String { + // Determine how many patterns are available (version pattern needs dot) + let max_pattern = if has_dot { 5 } else { 4 }; + let pattern = rng.gen_range(0..max_pattern); + let num = match pattern { + 0 => { + // Simple count: "3" or "42" + random_number(digits, 1, 3, focused_digit, rng) + } + 1 => { + // Measurement: "7 miles" or "42 items" + let num = random_number(digits, 1, 2, focused_digit, rng); + let units = ["items", "miles", "days", "lines", "times", "parts"]; + let unit = units[rng.gen_range(0..units.len())]; + return format!("{num} {unit}"); + } + 2 => { + // Year-like: "2024" + random_number(digits, 4, 4, focused_digit, rng) + } + 3 => { + // ID: "room 42" or "page 7" + let prefixes = ["room", "page", "step", "item", "line", "port"]; + let prefix = prefixes[rng.gen_range(0..prefixes.len())]; + let num = random_number(digits, 1, 3, focused_digit, rng); + return format!("{prefix} {num}"); + } + _ => { + // Version-like: "3.14" or "2.0" (only when dot is available) + let major = random_number(digits, 1, 1, focused_digit, rng); + let minor = random_number(digits, 1, 2, focused_digit, rng); + return format!("{major}.{minor}"); + } + }; + num +} + +fn random_number( + digits: &[char], + min_len: usize, + max_len: usize, + focused_digit: Option, + rng: &mut SmallRng, +) -> String { + let len = rng.gen_range(min_len..=max_len); + (0..len) + .map(|_| { + // 40% chance to use the focused digit if it's a digit + if let Some(fd) = focused_digit { + if rng.gen_bool(0.40) { + return fd; + } + } + digits[rng.gen_range(0..digits.len())] + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + + #[test] + fn test_no_numbers_when_empty() { + let mut rng = SmallRng::seed_from_u64(42); + let result = apply_numbers("hello world", &[], false, None, &mut rng); + assert_eq!(result, "hello world"); + } + + #[test] + fn test_numbers_use_only_unlocked_digits() { + let mut rng = SmallRng::seed_from_u64(42); + let digits = ['1', '2', '3']; + let text = "a b c d e f g h i j k l m n o p q r s t"; + let result = apply_numbers(text, &digits, false, None, &mut rng); + for ch in result.chars() { + if ch.is_ascii_digit() { + assert!(digits.contains(&ch), "Unexpected digit {ch} in: {result}"); + } + } + } + + #[test] + fn test_no_dot_without_punctuation() { + let mut rng = SmallRng::seed_from_u64(42); + let digits = ['1', '2', '3', '4', '5']; + let text = "a b c d e f g h i j k l m n o p q r s t"; + let result = apply_numbers(text, &digits, false, None, &mut rng); + assert!(!result.contains('.'), "Should not contain dot when has_dot=false: {result}"); + } +} diff --git a/src/generator/punctuate.rs b/src/generator/punctuate.rs new file mode 100644 index 0000000..54e096e --- /dev/null +++ b/src/generator/punctuate.rs @@ -0,0 +1,213 @@ +use rand::rngs::SmallRng; +use rand::Rng; + +/// Post-processing pass that inserts punctuation into generated text. +/// Only uses punctuation chars from `unlocked_punct`. +pub fn apply_punctuation( + text: &str, + unlocked_punct: &[char], + focused: Option, + rng: &mut SmallRng, +) -> String { + if unlocked_punct.is_empty() { + return text.to_string(); + } + + // If focused key is a punctuation char in our set, boost its insertion probability + let focused_punct = focused.filter(|ch| unlocked_punct.contains(ch)); + + let words: Vec<&str> = text.split(' ').collect(); + if words.is_empty() { + return text.to_string(); + } + + let has_period = unlocked_punct.contains(&'.'); + let has_comma = unlocked_punct.contains(&','); + let has_apostrophe = unlocked_punct.contains(&'\''); + let has_semicolon = unlocked_punct.contains(&';'); + let has_colon = unlocked_punct.contains(&':'); + let has_quote = unlocked_punct.contains(&'"'); + let has_dash = unlocked_punct.contains(&'-'); + let has_question = unlocked_punct.contains(&'?'); + let has_exclaim = unlocked_punct.contains(&'!'); + let has_open_paren = unlocked_punct.contains(&'('); + let has_close_paren = unlocked_punct.contains(&')'); + + let mut result = Vec::new(); + let mut words_since_period = 0; + let mut words_since_comma = 0; + + for (i, word) in words.iter().enumerate() { + let mut w = word.to_string(); + + // Contractions (~8% of words, boosted if apostrophe is focused) + let apostrophe_prob = if focused_punct == Some('\'') { 0.30 } else { 0.08 }; + if has_apostrophe && w.len() >= 3 && rng.gen_bool(apostrophe_prob) { + w = make_contraction(&w, rng); + } + + // Compound words with dash (~5% of words, boosted if dash is focused) + let dash_prob = if focused_punct == Some('-') { 0.25 } else { 0.05 }; + if has_dash && i + 1 < words.len() && rng.gen_bool(dash_prob) { + w.push('-'); + } + + // Sentence ending punctuation + words_since_period += 1; + let end_sentence = words_since_period >= 8 && rng.gen_bool(0.15) + || words_since_period >= 12; + + if end_sentence && i < words.len() - 1 { + let q_prob = if focused_punct == Some('?') { 0.40 } else { 0.15 }; + let excl_prob = if focused_punct == Some('!') { 0.40 } else { 0.10 }; + if has_question && rng.gen_bool(q_prob) { + w.push('?'); + } else if has_exclaim && rng.gen_bool(excl_prob) { + w.push('!'); + } else if has_period { + w.push('.'); + } + words_since_period = 0; + words_since_comma = 0; + } else { + // Comma after clause (~every 4-6 words) + words_since_comma += 1; + let comma_prob = if focused_punct == Some(',') { 0.40 } else { 0.20 }; + if has_comma && words_since_comma >= 4 && rng.gen_bool(comma_prob) && i < words.len() - 1 { + w.push(','); + words_since_comma = 0; + } + + // Semicolon between clauses (rare, boosted if focused) + let semi_prob = if focused_punct == Some(';') { 0.25 } else { 0.05 }; + if has_semicolon && words_since_comma >= 5 && rng.gen_bool(semi_prob) && i < words.len() - 1 { + w.push(';'); + words_since_comma = 0; + } + + // Colon before list-like content (rare, boosted if focused) + let colon_prob = if focused_punct == Some(':') { 0.20 } else { 0.03 }; + if has_colon && rng.gen_bool(colon_prob) && i < words.len() - 1 { + w.push(':'); + } + } + + // Quoted phrases (~5% chance to start a quote, boosted if focused) + let quote_prob = if focused_punct == Some('"') { 0.20 } else { 0.04 }; + if has_quote && rng.gen_bool(quote_prob) && i + 2 < words.len() { + w = format!("\"{w}"); + } + + // Parenthetical asides (rare, boosted if focused) + let paren_prob = if matches!(focused_punct, Some('(' | ')')) { 0.15 } else { 0.03 }; + if has_open_paren && has_close_paren && rng.gen_bool(paren_prob) && i + 2 < words.len() { + w = format!("({w}"); + } + + result.push(w); + } + + // End with period if we have it + if has_period { + if let Some(last) = result.last_mut() { + let last_char = last.chars().last(); + if !matches!(last_char, Some('.' | '?' | '!' | '"' | ')')) { + last.push('.'); + } + } + } + + // Close any open quotes/parens + let mut open_quotes = 0i32; + let mut open_parens = 0i32; + for w in &result { + for ch in w.chars() { + if ch == '"' { open_quotes += 1; } + if ch == '(' { open_parens += 1; } + if ch == ')' { open_parens -= 1; } + } + } + if let Some(last) = result.last_mut() { + if open_quotes % 2 != 0 && has_quote { + // Remove trailing period to put quote after + let had_period = last.ends_with('.'); + if had_period { + last.pop(); + } + last.push('"'); + if had_period { + last.push('.'); + } + } + if open_parens > 0 && has_close_paren { + let had_period = last.ends_with('.'); + if had_period { + last.pop(); + } + last.push(')'); + if had_period { + last.push('.'); + } + } + } + + result.join(" ") +} + +fn make_contraction(word: &str, rng: &mut SmallRng) -> String { + // Simple contractions based on common patterns + let contractions: &[(&str, &str)] = &[ + ("not", "n't"), + ("will", "'ll"), + ("would", "'d"), + ("have", "'ve"), + ("are", "'re"), + ("is", "'s"), + ]; + + for &(base, suffix) in contractions { + if word == base { + // For "not" -> "don't", "can't", etc. - just return the contraction form + return format!("{word}{suffix}"); + } + } + + // Generic: ~chance to add 's + if rng.gen_bool(0.5) { + format!("{word}'s") + } else { + word.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + + #[test] + fn test_no_punct_when_empty() { + let mut rng = SmallRng::seed_from_u64(42); + let result = apply_punctuation("hello world", &[], None, &mut rng); + assert_eq!(result, "hello world"); + } + + #[test] + fn test_adds_period_at_end() { + let mut rng = SmallRng::seed_from_u64(42); + let text = "one two three four five six seven eight nine ten"; + let result = apply_punctuation(text, &['.'], None, &mut rng); + assert!(result.ends_with('.')); + } + + #[test] + fn test_period_appears_mid_text() { + let mut rng = SmallRng::seed_from_u64(42); + let words: Vec<&str> = (0..20).map(|_| "word").collect(); + let text = words.join(" "); + let result = apply_punctuation(&text, &['.', ','], None, &mut rng); + // Should have at least one period somewhere in the middle + let period_count = result.chars().filter(|&c| c == '.').count(); + assert!(period_count >= 1, "Expected periods in: {result}"); + } +} diff --git a/src/main.rs b/src/main.rs index 0041988..95bba0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,9 @@ use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::Terminal; use app::{App, AppScreen, DrillMode}; +use engine::skill_tree::DrillScope; use session::result::DrillResult; +use ui::components::skill_tree::{SkillTreeWidget, selectable_branches}; use event::{AppEvent, EventHandler}; use ui::components::dashboard::Dashboard; use ui::components::keyboard_diagram::KeyboardDiagram; @@ -160,6 +162,7 @@ fn handle_key(app: &mut App, key: KeyEvent) { AppScreen::DrillResult => handle_result_key(app, key), AppScreen::StatsDashboard => handle_stats_key(app, key), AppScreen::Settings => handle_settings_key(app, key), + AppScreen::SkillTree => handle_skill_tree_key(app, key), } } @@ -168,16 +171,20 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) { KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, KeyCode::Char('1') => { app.drill_mode = DrillMode::Adaptive; + app.drill_scope = DrillScope::Global; app.start_drill(); } KeyCode::Char('2') => { app.drill_mode = DrillMode::Code; + app.drill_scope = DrillScope::Global; app.start_drill(); } KeyCode::Char('3') => { app.drill_mode = DrillMode::Passage; + app.drill_scope = DrillScope::Global; app.start_drill(); } + KeyCode::Char('t') => app.go_to_skill_tree(), KeyCode::Char('s') => app.go_to_stats(), KeyCode::Char('c') => app.go_to_settings(), KeyCode::Up | KeyCode::Char('k') => app.menu.prev(), @@ -185,18 +192,22 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) { KeyCode::Enter => match app.menu.selected { 0 => { app.drill_mode = DrillMode::Adaptive; + app.drill_scope = DrillScope::Global; app.start_drill(); } 1 => { app.drill_mode = DrillMode::Code; + app.drill_scope = DrillScope::Global; app.start_drill(); } 2 => { app.drill_mode = DrillMode::Passage; + app.drill_scope = DrillScope::Global; app.start_drill(); } - 3 => app.go_to_stats(), - 4 => app.go_to_settings(), + 3 => app.go_to_skill_tree(), + 4 => app.go_to_stats(), + 5 => app.go_to_settings(), _ => {} }, _ => {} @@ -204,13 +215,29 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) { } fn handle_drill_key(app: &mut App, key: KeyEvent) { + // Route Enter/Tab as typed characters during active drills + if app.drill.is_some() { + match key.code { + KeyCode::Enter => { + app.type_char('\n'); + return; + } + KeyCode::Tab => { + app.type_char('\t'); + return; + } + KeyCode::BackTab => return, // Ignore Shift+Tab + _ => {} + } + } + match key.code { KeyCode::Esc => { let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0); if has_progress && app.drill_mode != DrillMode::Adaptive { // Non-adaptive: show result screen for partial drill if let Some(ref drill) = app.drill { - let result = DrillResult::from_drill(drill, &app.drill_events, app.drill_mode.as_str()); + let result = DrillResult::from_drill(drill, &app.drill_events, app.drill_mode.as_str(), app.drill_mode.is_ranked()); app.last_result = Some(result); } app.screen = AppScreen::DrillResult; @@ -317,6 +344,33 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) { } } +fn handle_skill_tree_key(app: &mut App, key: KeyEvent) { + let branches = selectable_branches(); + match key.code { + KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), + KeyCode::Up | KeyCode::Char('k') => { + app.skill_tree_selected = app.skill_tree_selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + if app.skill_tree_selected + 1 < branches.len() { + app.skill_tree_selected += 1; + } + } + KeyCode::Enter => { + if app.skill_tree_selected < branches.len() { + let branch_id = branches[app.skill_tree_selected]; + let status = app.skill_tree.branch_status(branch_id).clone(); + if status == engine::skill_tree::BranchStatus::Available + || status == engine::skill_tree::BranchStatus::InProgress + { + app.start_branch_drill(branch_id); + } + } + } + _ => {} + } +} + fn render(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; @@ -330,6 +384,7 @@ fn render(frame: &mut ratatui::Frame, app: &App) { AppScreen::DrillResult => render_result(frame, app), AppScreen::StatsDashboard => render_stats(frame, app), AppScreen::Settings => render_settings(frame, app), + AppScreen::SkillTree => render_skill_tree(frame, app), } } @@ -352,11 +407,11 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) { String::new() }; let header_info = format!( - " Level {} | Score {:.0} | {}/{} letters{}", + " Level {} | Score {:.0} | {}/{} keys{}", crate::engine::scoring::level_from_score(app.profile.total_score), app.profile.total_score, - app.letter_unlock.unlocked_count(), - app.letter_unlock.total_letters(), + app.skill_tree.total_unlocked_count(), + app.skill_tree.total_unique_keys, streak_text, ); let header = Paragraph::new(Line::from(vec![ @@ -381,7 +436,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) { frame.render_widget(&app.menu, menu_area); let footer = Paragraph::new(Line::from(vec![Span::styled( - " [1-3] Start [s] Stats [q] Quit ", + " [1-3] Start [t] Skill Tree [s] Stats [q] Quit ", Style::default().fg(colors.text_pending()), )])); frame.render_widget(footer, layout[2]); @@ -397,8 +452,8 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { let mode_name = match app.drill_mode { DrillMode::Adaptive => "Adaptive", - DrillMode::Code => "Code", - DrillMode::Passage => "Passage", + DrillMode::Code => "Code (Unranked)", + DrillMode::Passage => "Passage (Unranked)", }; // For medium/narrow: show compact stats in header @@ -420,7 +475,8 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { frame.render_widget(header, app_layout.header); } else { let header_title = format!(" {mode_name} Drill "); - let focus_text = if let Some(focused) = app.letter_unlock.focused { + let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats); + let focus_text = if let Some(focused) = focused { format!(" | Focus: '{focused}'") } else { String::new() @@ -466,9 +522,12 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { let mut idx = 1; if show_progress { + let unlocked = app.skill_tree.total_unlocked_count() as f64; + let total = app.skill_tree.total_unique_keys as f64; + let progress_val = (unlocked / total).min(1.0); let progress = ProgressBar::new( - "Letter Progress", - app.letter_unlock.progress(), + "Key Progress", + progress_val, app.theme, ); frame.render_widget(progress, main_layout[idx]); @@ -477,10 +536,12 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { if show_kbd { let next_char = drill.target.get(drill.cursor).copied(); + let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope); + let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats); let kbd = KeyboardDiagram::new( - app.letter_unlock.focused, + focused, next_char, - &app.letter_unlock.included, + &unlocked_keys, &app.depressed_keys, app.theme, ) @@ -609,3 +670,15 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { ))); footer.render(layout[3], frame.buffer_mut()); } + +fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let centered = ui::layout::centered_rect(70, 90, area); + let widget = SkillTreeWidget::new( + &app.skill_tree, + &app.key_stats, + app.skill_tree_selected, + app.theme, + ); + frame.render_widget(widget, centered); +} diff --git a/src/session/result.rs b/src/session/result.rs index 3f058d6..de51559 100644 --- a/src/session/result.rs +++ b/src/session/result.rs @@ -17,12 +17,18 @@ pub struct DrillResult { pub per_key_times: Vec, #[serde(default = "default_drill_mode", alias = "lesson_mode")] pub drill_mode: String, + #[serde(default = "default_true")] + pub ranked: bool, } fn default_drill_mode() -> String { "adaptive".to_string() } +fn default_true() -> bool { + true +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct KeyTime { pub key: char, @@ -31,7 +37,7 @@ pub struct KeyTime { } impl DrillResult { - pub fn from_drill(drill: &DrillState, events: &[KeystrokeEvent], drill_mode: &str) -> Self { + pub fn from_drill(drill: &DrillState, events: &[KeystrokeEvent], drill_mode: &str, ranked: bool) -> Self { let per_key_times: Vec = events .windows(2) .map(|pair| { @@ -63,6 +69,7 @@ impl DrillResult { timestamp: Utc::now(), per_key_times, drill_mode: drill_mode.to_string(), + ranked, } } } diff --git a/src/store/json_store.rs b/src/store/json_store.rs index c3f4f1f..ce974da 100644 --- a/src/store/json_store.rs +++ b/src/store/json_store.rs @@ -49,8 +49,17 @@ impl JsonStore { Ok(()) } - pub fn load_profile(&self) -> ProfileData { - self.load("profile.json") + /// Load and deserialize profile. Returns None if file exists but + /// cannot be parsed (schema mismatch / corruption). + pub fn load_profile(&self) -> Option { + let path = self.file_path("profile.json"); + if path.exists() { + let content = fs::read_to_string(&path).ok()?; + serde_json::from_str(&content).ok() + } else { + // No file yet — return fresh default (not a schema mismatch) + Some(ProfileData::default()) + } } pub fn save_profile(&self, data: &ProfileData) -> Result<()> { diff --git a/src/store/schema.rs b/src/store/schema.rs index c656c83..9756e38 100644 --- a/src/store/schema.rs +++ b/src/store/schema.rs @@ -1,14 +1,15 @@ use serde::{Deserialize, Serialize}; use crate::engine::key_stats::KeyStatsStore; +use crate::engine::skill_tree::SkillTreeProgress; use crate::session::result::DrillResult; -const SCHEMA_VERSION: u32 = 1; +const SCHEMA_VERSION: u32 = 2; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ProfileData { pub schema_version: u32, - pub unlocked_letters: Vec, + pub skill_tree: SkillTreeProgress, pub total_score: f64, #[serde(alias = "total_lessons")] pub total_drills: u32, @@ -21,7 +22,7 @@ impl Default for ProfileData { fn default() -> Self { Self { schema_version: SCHEMA_VERSION, - unlocked_letters: Vec::new(), + skill_tree: SkillTreeProgress::default(), total_score: 0.0, total_drills: 0, streak_days: 0, @@ -31,6 +32,13 @@ impl Default for ProfileData { } } +impl ProfileData { + /// Check if loaded data has a stale schema version and needs reset. + pub fn needs_reset(&self) -> bool { + self.schema_version != SCHEMA_VERSION + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct KeyStatsData { pub schema_version: u32, diff --git a/src/ui/components/dashboard.rs b/src/ui/components/dashboard.rs index b81c1bc..eccdca0 100644 --- a/src/ui/components/dashboard.rs +++ b/src/ui/components/dashboard.rs @@ -42,13 +42,20 @@ impl Widget for Dashboard<'_> { ]) .split(inner); - let title = Paragraph::new(Line::from(Span::styled( + let mut title_spans = vec![Span::styled( "Results", Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), - ))) - .alignment(Alignment::Center); + )]; + if !self.result.ranked { + title_spans.push(Span::styled( + " (Unranked \u{2014} does not count toward skill tree)", + Style::default().fg(colors.text_pending()), + )); + } + let title = Paragraph::new(Line::from(title_spans)) + .alignment(Alignment::Center); title.render(layout[0], buf); let wpm_text = format!("{:.0} WPM", self.result.wpm); diff --git a/src/ui/components/menu.rs b/src/ui/components/menu.rs index 8accd69..f162e0c 100644 --- a/src/ui/components/menu.rs +++ b/src/ui/components/menu.rs @@ -37,6 +37,11 @@ impl<'a> Menu<'a> { label: "Passage Drill".to_string(), description: "Type passages from books".to_string(), }, + MenuItem { + key: "t".to_string(), + label: "Skill Tree".to_string(), + description: "View progression branches and launch drills".to_string(), + }, MenuItem { key: "s".to_string(), label: "Statistics".to_string(), diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 3e23ef0..640b1f3 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -4,6 +4,7 @@ pub mod dashboard; pub mod keyboard_diagram; pub mod menu; pub mod progress_bar; +pub mod skill_tree; pub mod stats_dashboard; pub mod stats_sidebar; pub mod typing_area; diff --git a/src/ui/components/skill_tree.rs b/src/ui/components/skill_tree.rs new file mode 100644 index 0000000..3ce7934 --- /dev/null +++ b/src/ui/components/skill_tree.rs @@ -0,0 +1,354 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Widget}; + +use crate::engine::key_stats::KeyStatsStore; +use crate::engine::skill_tree::{ + BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine, + get_branch_definition, +}; +use crate::ui::theme::Theme; + +pub struct SkillTreeWidget<'a> { + skill_tree: &'a SkillTreeEngine, + key_stats: &'a KeyStatsStore, + selected: usize, + theme: &'a Theme, +} + +impl<'a> SkillTreeWidget<'a> { + pub fn new( + skill_tree: &'a SkillTreeEngine, + key_stats: &'a KeyStatsStore, + selected: usize, + theme: &'a Theme, + ) -> Self { + Self { + skill_tree, + key_stats, + selected, + theme, + } + } +} + +/// Get the list of selectable branch IDs (all non-Lowercase branches). +pub fn selectable_branches() -> Vec { + vec![ + BranchId::Capitals, + BranchId::Numbers, + BranchId::ProsePunctuation, + BranchId::Whitespace, + BranchId::CodeSymbols, + ] +} + +impl Widget for SkillTreeWidget<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let colors = &self.theme.colors; + + let block = Block::bordered() + .title(" Skill Tree ") + .border_style(Style::default().fg(colors.accent())) + .style(Style::default().bg(colors.bg())); + let inner = block.inner(area); + block.render(area, buf); + + // Layout: header (2), branch list (dynamic), separator (1), detail panel (dynamic), footer (2) + let branches = selectable_branches(); + let branch_list_height = 3 + branches.len() as u16 * 2 + 1; // root + separator + 5 branches + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(branch_list_height.min(inner.height.saturating_sub(6))), + Constraint::Length(1), + Constraint::Min(4), + Constraint::Length(2), + ]) + .split(inner); + + // --- Branch list --- + self.render_branch_list(layout[0], buf, &branches); + + // --- Separator --- + let sep = Paragraph::new(Line::from(Span::styled( + "\u{2500}".repeat(layout[1].width as usize), + Style::default().fg(colors.border()), + ))); + sep.render(layout[1], buf); + + // --- Detail panel for selected branch --- + self.render_detail_panel(layout[2], buf, &branches); + + // --- Footer --- + let footer_text = if self.selected < branches.len() { + let bp = self.skill_tree.branch_progress(branches[self.selected]); + if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked { + " Complete a-z to unlock branches " + } else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress { + " [Enter] Start Drill [\u{2191}\u{2193}/jk] Navigate [q] Back " + } else { + " [\u{2191}\u{2193}/jk] Navigate [q] Back " + } + } else { + " [\u{2191}\u{2193}/jk] Navigate [q] Back " + }; + + let footer = Paragraph::new(Line::from(Span::styled( + footer_text, + Style::default().fg(colors.text_pending()), + ))); + footer.render(layout[3], buf); + } +} + +impl SkillTreeWidget<'_> { + fn render_branch_list(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) { + let colors = &self.theme.colors; + let mut lines: Vec = Vec::new(); + + // Root: Lowercase a-z + let lowercase_bp = self.skill_tree.branch_progress(BranchId::Lowercase); + let lowercase_def = get_branch_definition(BranchId::Lowercase); + let lowercase_total = lowercase_def.levels.iter().map(|l| l.keys.len()).sum::(); + let lowercase_confident = self.skill_tree.branch_confident_keys(BranchId::Lowercase, self.key_stats); + + let (prefix, style) = match lowercase_bp.status { + BranchStatus::Complete => ( + "\u{2605} ", + Style::default().fg(colors.text_correct()).add_modifier(Modifier::BOLD), + ), + BranchStatus::InProgress => ( + "\u{25b6} ", + Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD), + ), + _ => ( + " ", + Style::default().fg(colors.text_pending()), + ), + }; + + let status_text = match lowercase_bp.status { + BranchStatus::Complete => "COMPLETE".to_string(), + BranchStatus::InProgress => { + let unlocked = self.skill_tree.lowercase_unlocked_count(); + format!("{unlocked}/{lowercase_total}") + } + _ => "LOCKED".to_string(), + }; + + lines.push(Line::from(vec![ + Span::styled( + format!(" {prefix}{name}", name = lowercase_def.name), + style, + ), + Span::styled( + format!(" {status_text} {lowercase_confident}/{lowercase_total} keys"), + Style::default().fg(colors.text_pending()), + ), + ])); + + // Progress bar for lowercase + let pct = if lowercase_total > 0 { + lowercase_confident as f64 / lowercase_total as f64 + } else { + 0.0 + }; + lines.push(Line::from(Span::styled( + format!(" {}", progress_bar_str(pct, 30)), + style, + ))); + + // Separator + lines.push(Line::from(Span::styled( + " \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}", + Style::default().fg(colors.border()), + ))); + + // Branches + for (i, &branch_id) in branches.iter().enumerate() { + let bp = self.skill_tree.branch_progress(branch_id); + let def = get_branch_definition(branch_id); + let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::(); + let confident_keys = self.skill_tree.branch_confident_keys(branch_id, self.key_stats); + let is_selected = i == self.selected; + + let (prefix, style) = match bp.status { + BranchStatus::Complete => ( + "\u{2605} ", + if is_selected { + Style::default().fg(colors.text_correct()).add_modifier(Modifier::BOLD | Modifier::REVERSED) + } else { + Style::default().fg(colors.text_correct()).add_modifier(Modifier::BOLD) + }, + ), + BranchStatus::InProgress => ( + "\u{25b6} ", + if is_selected { + Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD | Modifier::REVERSED) + } else { + Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD) + }, + ), + BranchStatus::Available => ( + " ", + if is_selected { + Style::default().fg(colors.fg()).add_modifier(Modifier::BOLD | Modifier::REVERSED) + } else { + Style::default().fg(colors.fg()) + }, + ), + BranchStatus::Locked => ( + " ", + Style::default().fg(colors.text_pending()), + ), + }; + + let status_text = match bp.status { + BranchStatus::Complete => format!("COMPLETE {confident_keys}/{total_keys} keys"), + BranchStatus::InProgress => format!("Lvl {}/{} {confident_keys}/{total_keys} keys", bp.current_level + 1, def.levels.len()), + BranchStatus::Available => format!("Available 0/{total_keys} keys"), + BranchStatus::Locked => format!("Locked 0/{total_keys} keys"), + }; + + let sel_indicator = if is_selected { "> " } else { " " }; + + lines.push(Line::from(vec![ + Span::styled(format!("{sel_indicator}{prefix}{}", def.name), style), + Span::styled(format!(" {status_text}"), Style::default().fg(colors.text_pending())), + ])); + + let pct = if total_keys > 0 { + confident_keys as f64 / total_keys as f64 + } else { + 0.0 + }; + lines.push(Line::from(Span::styled( + format!(" {}", progress_bar_str(pct, 30)), + style, + ))); + } + + let paragraph = Paragraph::new(lines); + paragraph.render(area, buf); + } + + fn render_detail_panel(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) { + let colors = &self.theme.colors; + + if self.selected >= branches.len() { + return; + } + + let branch_id = branches[self.selected]; + let bp = self.skill_tree.branch_progress(branch_id); + let def = get_branch_definition(branch_id); + + let mut lines: Vec = Vec::new(); + + // Branch title with level info + let level_text = match bp.status { + BranchStatus::InProgress => format!("Level {}/{}", bp.current_level + 1, def.levels.len()), + BranchStatus::Complete => format!("Level {}/{}", def.levels.len(), def.levels.len()), + _ => format!("Level 0/{}", def.levels.len()), + }; + lines.push(Line::from(vec![ + Span::styled( + format!(" {}", def.name), + Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {level_text}"), + Style::default().fg(colors.text_pending()), + ), + ])); + + // Per-level key breakdown + let focused = self.skill_tree.focused_key(DrillScope::Branch(branch_id), self.key_stats); + + for (level_idx, level) in def.levels.iter().enumerate() { + let level_status = if bp.status == BranchStatus::Complete || level_idx < bp.current_level { + "complete" + } else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level { + "in progress" + } else { + "locked" + }; + + let mut key_spans: Vec = Vec::new(); + key_spans.push(Span::styled( + format!(" L{}: ", level_idx + 1), + Style::default().fg(colors.fg()), + )); + + for &key in level.keys { + let is_confident = self.key_stats.get_confidence(key) >= 1.0; + let is_focused = focused == Some(key); + + let display = if key == '\n' { + "\\n".to_string() + } else if key == '\t' { + "\\t".to_string() + } else { + key.to_string() + }; + + let style = if is_focused { + Style::default() + .fg(colors.bg()) + .bg(colors.focused_key()) + .add_modifier(Modifier::BOLD) + } else if is_confident { + Style::default().fg(colors.text_correct()) + } else if level_status == "locked" { + Style::default().fg(colors.text_pending()) + } else { + Style::default().fg(colors.fg()) + }; + + key_spans.push(Span::styled(display, style)); + key_spans.push(Span::raw(" ")); + } + + key_spans.push(Span::styled( + format!(" ({level_status})"), + Style::default().fg(colors.text_pending()), + )); + + lines.push(Line::from(key_spans)); + } + + // Average confidence + let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::(); + let avg_conf = if total_keys > 0 { + let sum: f64 = def.levels.iter() + .flat_map(|l| l.keys.iter()) + .map(|&ch| self.key_stats.get_confidence(ch).min(1.0)) + .sum(); + sum / total_keys as f64 + } else { + 0.0 + }; + + lines.push(Line::from(Span::styled( + format!(" Avg Confidence: {} {:.0}%", progress_bar_str(avg_conf, 20), avg_conf * 100.0), + Style::default().fg(colors.text_pending()), + ))); + + let paragraph = Paragraph::new(lines); + paragraph.render(area, buf); + } +} + +fn progress_bar_str(pct: f64, width: usize) -> String { + let filled = (pct * width as f64).round() as usize; + let empty = width.saturating_sub(filled); + format!( + "{}{}", + "\u{2588}".repeat(filled), + "\u{2591}".repeat(empty), + ) +} diff --git a/src/ui/components/stats_dashboard.rs b/src/ui/components/stats_dashboard.rs index 5ecaf74..326c63c 100644 --- a/src/ui/components/stats_dashboard.rs +++ b/src/ui/components/stats_dashboard.rs @@ -488,7 +488,7 @@ impl StatsDashboard<'_> { table_block.render(layout[0], buf); let header = Line::from(vec![Span::styled( - " # WPM Raw Acc% Time Date", + " # WPM Raw Acc% Time Date Mode", Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -523,8 +523,14 @@ impl StatsDashboard<'_> { " " }; + let mode_str = if result.ranked { + "" + } else { + " (unranked)" + }; let row = format!( - " {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str}" + " {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str} {mode}{mode_str}", + mode = result.drill_mode, ); let acc_color = if result.accuracy >= 95.0 { @@ -538,6 +544,9 @@ impl StatsDashboard<'_> { let is_selected = i == self.history_selected; let style = if is_selected { Style::default().fg(acc_color).bg(colors.accent_dim()) + } else if !result.ranked { + // Muted styling for unranked drills + Style::default().fg(colors.text_pending()) } else { Style::default().fg(acc_color) }; diff --git a/src/ui/components/typing_area.rs b/src/ui/components/typing_area.rs index 1873e8c..dcc553b 100644 --- a/src/ui/components/typing_area.rs +++ b/src/ui/components/typing_area.rs @@ -19,43 +19,172 @@ impl<'a> TypingArea<'a> { } } +/// A render token maps a single target character to its display representation. +struct RenderToken { + target_idx: usize, + display: String, + is_line_break: bool, +} + +/// Expand target chars into render tokens, handling whitespace display. +fn build_render_tokens(target: &[char]) -> Vec { + let mut tokens = Vec::new(); + let mut col = 0usize; + + for (i, &ch) in target.iter().enumerate() { + match ch { + '\n' => { + tokens.push(RenderToken { + target_idx: i, + display: "\u{21b5}".to_string(), // ↵ + is_line_break: true, + }); + col = 0; + } + '\t' => { + let tab_width = 4 - (col % 4); + let mut display = String::from("\u{2192}"); // → + for _ in 1..tab_width { + display.push('\u{00b7}'); // · + } + tokens.push(RenderToken { + target_idx: i, + display, + is_line_break: false, + }); + col += tab_width; + } + _ => { + tokens.push(RenderToken { + target_idx: i, + display: ch.to_string(), + is_line_break: false, + }); + col += 1; + } + } + } + + tokens +} + impl Widget for TypingArea<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; - let mut spans: Vec = Vec::new(); + let tokens = build_render_tokens(&self.drill.target); - for (i, &target_ch) in self.drill.target.iter().enumerate() { - if i < self.drill.cursor { - let style = match &self.drill.input[i] { + // Group tokens into lines, splitting on line_break tokens + let mut lines: Vec> = vec![Vec::new()]; + + for token in &tokens { + let idx = token.target_idx; + + let style = if idx < self.drill.cursor { + match &self.drill.input[idx] { CharStatus::Correct => Style::default().fg(colors.text_correct()), CharStatus::Incorrect(_) => Style::default() .fg(colors.text_incorrect()) .bg(colors.text_incorrect_bg()) .add_modifier(Modifier::UNDERLINED), - }; - let display = match &self.drill.input[i] { - CharStatus::Incorrect(actual) => *actual, - _ => target_ch, - }; - spans.push(Span::styled(display.to_string(), style)); - } else if i == self.drill.cursor { - let style = Style::default() + } + } else if idx == self.drill.cursor { + Style::default() .fg(colors.text_cursor_fg()) - .bg(colors.text_cursor_bg()); - spans.push(Span::styled(target_ch.to_string(), style)); + .bg(colors.text_cursor_bg()) } else { - let style = Style::default().fg(colors.text_pending()); - spans.push(Span::styled(target_ch.to_string(), style)); + Style::default().fg(colors.text_pending()) + }; + + // For incorrect chars, show the actual typed char for regular chars, + // but always show the token display for whitespace markers + let display = if idx < self.drill.cursor { + if let CharStatus::Incorrect(actual) = &self.drill.input[idx] { + let target_ch = self.drill.target[idx]; + if target_ch == '\n' || target_ch == '\t' { + // Show the whitespace marker even when incorrect + token.display.clone() + } else { + actual.to_string() + } + } else { + token.display.clone() + } + } else { + token.display.clone() + }; + + lines.last_mut().unwrap().push(Span::styled(display, style)); + + if token.is_line_break { + lines.push(Vec::new()); } } - let line = Line::from(spans); + let ratatui_lines: Vec = lines.into_iter().map(Line::from).collect(); + let block = Block::bordered() .border_style(Style::default().fg(colors.border())) .style(Style::default().bg(colors.bg())); - let paragraph = Paragraph::new(line).block(block).wrap(Wrap { trim: false }); + let paragraph = Paragraph::new(ratatui_lines) + .block(block) + .wrap(Wrap { trim: false }); paragraph.render(area, buf); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render_tokens_basic() { + let target: Vec = "abc".chars().collect(); + let tokens = build_render_tokens(&target); + assert_eq!(tokens.len(), 3); + assert_eq!(tokens[0].display, "a"); + assert_eq!(tokens[1].display, "b"); + assert_eq!(tokens[2].display, "c"); + assert!(!tokens[0].is_line_break); + } + + #[test] + fn test_render_tokens_newline() { + let target: Vec = "a\nb".chars().collect(); + let tokens = build_render_tokens(&target); + assert_eq!(tokens.len(), 3); + assert_eq!(tokens[1].display, "\u{21b5}"); // ↵ + assert!(tokens[1].is_line_break); + assert_eq!(tokens[1].target_idx, 1); + } + + #[test] + fn test_render_tokens_tab() { + let target: Vec = "\tx".chars().collect(); + let tokens = build_render_tokens(&target); + assert_eq!(tokens.len(), 2); + // Tab at col 0: width = 4 - (0 % 4) = 4 => "→···" + assert_eq!(tokens[0].display, "\u{2192}\u{00b7}\u{00b7}\u{00b7}"); + assert!(!tokens[0].is_line_break); + assert_eq!(tokens[0].target_idx, 0); + } + + #[test] + fn test_render_tokens_tab_alignment() { + // "ab\t" -> col 2, tab_width = 4 - (2 % 4) = 2 => "→·" + let target: Vec = "ab\t".chars().collect(); + let tokens = build_render_tokens(&target); + assert_eq!(tokens[2].display, "\u{2192}\u{00b7}"); + } + + #[test] + fn test_render_tokens_newline_resets_column() { + // "\n\tx" -> after newline, col resets to 0, tab_width = 4 + let target: Vec = "\n\tx".chars().collect(); + let tokens = build_render_tokens(&target); + assert_eq!(tokens.len(), 3); + assert!(tokens[0].is_line_break); + assert_eq!(tokens[1].display, "\u{2192}\u{00b7}\u{00b7}\u{00b7}"); + } +}