Files
keydr/src/engine/skill_tree.rs
Tyler Hallada 6d5de33f55 Internationalize UI text w/ german as first second lang
Adds rust-i18n and refactors all of the text copy in the app to use the
translation function so that the UI language can be dynamically updated
in the settings.
2026-03-17 04:29:25 +00:00

1572 lines
52 KiB
Rust

use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::engine::key_stats::KeyStatsStore;
use crate::keyboard::display::{BACKSPACE, SPACE};
use crate::l10n::language_pack::{
DEFAULT_LATIN_PRIMARY_SEQUENCE, normalized_primary_letter_sequence,
};
/// Events returned by `SkillTree::update` describing what changed.
pub struct SkillTreeUpdate {
pub newly_unlocked: Vec<char>,
pub newly_mastered: Vec<char>,
pub branches_newly_available: Vec<BranchId>,
pub branches_newly_completed: Vec<BranchId>,
pub all_keys_unlocked: bool,
pub all_keys_mastered: bool,
}
// --- 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",
}
}
#[allow(dead_code)]
pub fn from_key(key: &str) -> Option<Self> {
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_key: &'static str,
pub keys: &'static [char],
}
impl LevelDefinition {
pub fn display_name(&self) -> String {
crate::i18n::t!(self.name_key).to_string()
}
}
pub struct BranchDefinition {
pub id: BranchId,
pub name_key: &'static str,
pub levels: &'static [LevelDefinition],
}
impl BranchDefinition {
pub fn display_name(&self) -> String {
crate::i18n::t!(self.name_key).to_string()
}
}
// Lowercase metadata remains for static branch lookup/UI labels. Runtime
// progression and unlock counts are driven by `SkillTree::primary_letters`.
const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition {
name_key: "skill_tree.level_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_key: "skill_tree.level_common_sentence_capitals",
keys: &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'],
},
LevelDefinition {
name_key: "skill_tree.level_name_capitals",
keys: &['J', 'D', 'R', 'C', 'E', 'N', 'P', 'L', 'F', 'G'],
},
LevelDefinition {
name_key: "skill_tree.level_remaining_capitals",
keys: &['O', 'U', 'K', 'V', 'Y', 'X', 'Q', 'Z'],
},
];
const NUMBERS_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name_key: "skill_tree.level_common_digits",
keys: &['1', '2', '3', '4', '5'],
},
LevelDefinition {
name_key: "skill_tree.level_all_digits",
keys: &['0', '6', '7', '8', '9'],
},
];
const PROSE_PUNCTUATION_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name_key: "skill_tree.level_essential",
keys: &['.', ',', '\''],
},
LevelDefinition {
name_key: "skill_tree.level_common",
keys: &[';', ':', '"', '-'],
},
LevelDefinition {
name_key: "skill_tree.level_expressive",
keys: &['?', '!', '(', ')'],
},
];
const WHITESPACE_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name_key: "skill_tree.level_enter_return",
keys: &['\n'],
},
LevelDefinition {
name_key: "skill_tree.level_tab_indent",
keys: &['\t'],
},
];
const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name_key: "skill_tree.level_arithmetic_assignment",
keys: &['=', '+', '*', '/', '-'],
},
LevelDefinition {
name_key: "skill_tree.level_grouping",
keys: &['{', '}', '[', ']', '<', '>'],
},
LevelDefinition {
name_key: "skill_tree.level_logic_reference",
keys: &['&', '|', '^', '~', '!'],
},
LevelDefinition {
name_key: "skill_tree.level_special",
keys: &['@', '#', '$', '%', '_', '\\', '`'],
},
];
pub const ALL_BRANCHES: &[BranchDefinition] = &[
BranchDefinition {
id: BranchId::Lowercase,
name_key: "skill_tree.branch_primary_letters",
levels: LOWERCASE_LEVELS,
},
BranchDefinition {
id: BranchId::Capitals,
name_key: "skill_tree.branch_capital_letters",
levels: CAPITALS_LEVELS,
},
BranchDefinition {
id: BranchId::Numbers,
name_key: "skill_tree.branch_numbers",
levels: NUMBERS_LEVELS,
},
BranchDefinition {
id: BranchId::ProsePunctuation,
name_key: "skill_tree.branch_prose_punctuation",
levels: PROSE_PUNCTUATION_LEVELS,
},
BranchDefinition {
id: BranchId::Whitespace,
name_key: "skill_tree.branch_whitespace",
levels: WHITESPACE_LEVELS,
},
BranchDefinition {
id: BranchId::CodeSymbols,
name_key: "skill_tree.branch_code_symbols",
levels: CODE_SYMBOLS_LEVELS,
},
];
/// Find which branch and level a key belongs to.
/// Returns (branch_def, level_def, 1-based position in level).
pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static LevelDefinition, usize)> {
for branch in ALL_BRANCHES {
for level in branch.levels {
if let Some(pos) = level.keys.iter().position(|&k| k == ch) {
return Some((branch, level, pos + 1));
}
}
}
None
}
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<String, BranchProgress>,
}
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 + primary-letter background
Branch(BranchId),
}
pub struct SkillTree {
pub progress: SkillTreeProgress,
pub total_unique_keys: usize,
primary_letters: Vec<char>,
}
/// Number of lowercase letters to start with before unlocking one-at-a-time
const LOWERCASE_MIN_KEYS: usize = 6;
const ALWAYS_UNLOCKED_KEYS: &[char] = &[SPACE, BACKSPACE];
impl SkillTree {
pub fn new(progress: SkillTreeProgress) -> Self {
Self::new_with_primary_sequence(progress, DEFAULT_LATIN_PRIMARY_SEQUENCE)
}
pub fn new_with_primary_sequence(progress: SkillTreeProgress, sequence: &str) -> Self {
let primary_letters = Self::normalize_primary_sequence(sequence);
let total_unique_keys = Self::compute_total_unique_keys(&primary_letters);
Self {
progress,
total_unique_keys,
primary_letters,
}
}
fn normalize_primary_sequence(sequence: &str) -> Vec<char> {
let normalized = normalized_primary_letter_sequence(sequence);
if normalized.is_empty() {
DEFAULT_LATIN_PRIMARY_SEQUENCE.chars().collect()
} else {
normalized
}
}
fn compute_total_unique_keys(primary_letters: &[char]) -> usize {
let mut all_keys: HashSet<char> = HashSet::new();
for branch in ALL_BRANCHES {
if branch.id == BranchId::Lowercase {
continue;
}
for level in branch.levels {
for &key in level.keys {
all_keys.insert(key);
}
}
}
all_keys.extend(primary_letters.iter().copied());
all_keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied());
all_keys.len()
}
pub fn primary_letters(&self) -> &[char] {
&self.primary_letters
}
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<char> {
match scope {
DrillScope::Global => self.global_unlocked_keys(),
DrillScope::Branch(id) => self.branch_unlocked_keys(id),
}
}
fn global_unlocked_keys(&self) -> Vec<char> {
let mut keys = ALWAYS_UNLOCKED_KEYS.to_vec();
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 => {
if branch_def.id == BranchId::Lowercase {
keys.extend(self.primary_letters.iter().copied());
} else {
for level in branch_def.levels {
keys.extend_from_slice(level.keys);
}
}
}
_ => {}
}
}
keys
}
fn branch_unlocked_keys(&self, id: BranchId) -> Vec<char> {
let mut keys = ALWAYS_UNLOCKED_KEYS.to_vec();
// Always include primary-letter background keys
if id != BranchId::Lowercase {
let lowercase_bp = self.branch_progress(BranchId::Lowercase);
match lowercase_bp.status {
BranchStatus::InProgress => keys.extend(self.lowercase_unlocked_keys()),
BranchStatus::Complete => {
keys.extend(self.primary_letters.iter().copied());
}
_ => {}
}
}
// 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<char> {
let bp = self.branch_progress(BranchId::Lowercase);
let all_keys = self.primary_letters();
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<char> {
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<char> {
// 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 => {
if branch_def.id == BranchId::Lowercase {
focus_candidates.extend(self.primary_letters.iter().copied());
} else {
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<char> {
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(&current_keys.to_vec(), stats)
}
_ => None,
}
}
fn weakest_key(keys: &[char], stats: &KeyStatsStore) -> Option<char> {
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.
///
/// `before_stats` is an optional snapshot of key stats *before* this drill's data was added.
/// When provided, it's used to detect which keys were newly mastered (confidence crossing 1.0).
/// Returns a `SkillTreeUpdate` describing which keys were newly unlocked or mastered.
pub fn update(
&mut self,
stats: &KeyStatsStore,
before_stats: Option<&KeyStatsStore>,
) -> SkillTreeUpdate {
// Snapshot unlocked keys before tree structure changes
let before_unlocked: HashSet<char> =
self.unlocked_keys(DrillScope::Global).into_iter().collect();
// Snapshot branch statuses before any updates
let before_branch_statuses: HashMap<BranchId, BranchStatus> = BranchId::all()
.iter()
.map(|&id| (id, self.branch_status(id).clone()))
.collect();
let before_unlocked_count = self.total_unlocked_count();
// Update lowercase branch (progressive unlock)
self.update_lowercase(stats);
// Check if lowercase is complete -> unlock other branches
// Snapshot non-lowercase branch statuses before auto-unlock (canonical order)
const NON_LOWERCASE_BRANCHES: &[BranchId] = &[
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
];
let before_auto_unlock: Vec<(BranchId, BranchStatus)> = NON_LOWERCASE_BRANCHES
.iter()
.map(|&id| (id, self.branch_status(id).clone()))
.collect();
if *self.branch_status(BranchId::Lowercase) == BranchStatus::Complete {
for &id in NON_LOWERCASE_BRANCHES {
let bp = self.branch_progress_mut(id);
if bp.status == BranchStatus::Locked {
bp.status = BranchStatus::Available;
}
}
}
// Detect Locked → Available transitions (maintains canonical order)
let branches_newly_available: Vec<BranchId> = before_auto_unlock
.iter()
.filter(|(id, before_status)| {
*before_status == BranchStatus::Locked
&& *self.branch_status(*id) == BranchStatus::Available
})
.map(|(id, _)| *id)
.collect();
// 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);
}
// Detect branches that became Complete
let branches_newly_completed: Vec<BranchId> = BranchId::all()
.iter()
.filter(|&&id| {
before_branch_statuses
.get(&id)
.map(|s| *s != BranchStatus::Complete)
.unwrap_or(true)
&& *self.branch_status(id) == BranchStatus::Complete
})
.copied()
.collect();
// Detect all keys unlocked
let after_unlocked_count = self.total_unlocked_count();
let all_keys_unlocked = after_unlocked_count == self.total_unique_keys
&& before_unlocked_count != self.total_unique_keys;
// Detect all keys mastered
let all_complete_now = BranchId::all()
.iter()
.all(|&id| *self.branch_status(id) == BranchStatus::Complete);
let all_complete_before = BranchId::all()
.iter()
.all(|id| before_branch_statuses.get(id) == Some(&BranchStatus::Complete));
let all_keys_mastered = all_complete_now && !all_complete_before;
// Snapshot after
let after_unlocked: HashSet<char> =
self.unlocked_keys(DrillScope::Global).into_iter().collect();
let newly_unlocked: Vec<char> = after_unlocked
.difference(&before_unlocked)
.copied()
.collect();
// Detect mastery: keys that were unlocked before, had confidence < 1.0 in before_stats,
// but now have confidence >= 1.0 in current stats
let newly_mastered: Vec<char> = if let Some(before) = before_stats {
before_unlocked
.iter()
.filter(|&&ch| before.get_confidence(ch) < 1.0 && stats.get_confidence(ch) >= 1.0)
.copied()
.collect()
} else {
Vec::new()
};
SkillTreeUpdate {
newly_unlocked,
newly_mastered,
branches_newly_available,
branches_newly_completed,
all_keys_unlocked,
all_keys_mastered,
}
}
fn update_lowercase(&mut self, stats: &KeyStatsStore) {
let bp = self.branch_progress(BranchId::Lowercase).clone();
if bp.status != BranchStatus::InProgress {
return;
}
let all_keys = self.primary_letters.clone();
let current_count = LOWERCASE_MIN_KEYS + bp.current_level;
if current_count >= all_keys.len() {
// All primary letters 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<char> = HashSet::new();
keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied());
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 => {
if branch_def.id == BranchId::Lowercase {
for &key in self.primary_letters() {
keys.insert(key);
}
} else {
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).
#[allow(dead_code)]
pub fn all_branches_with_progress(&self) -> Vec<(&'static BranchDefinition, &BranchProgress)> {
ALL_BRANCHES
.iter()
.map(|def| (def, self.branch_progress(def.id)))
.collect()
}
/// Number of unlocked keys in a branch.
pub fn branch_unlocked_count(&self, id: BranchId) -> usize {
let def = get_branch_definition(id);
let bp = self.branch_progress(id);
match bp.status {
BranchStatus::Complete => {
if id == BranchId::Lowercase {
self.primary_letters().len()
} else {
def.levels.iter().map(|l| l.keys.len()).sum()
}
}
BranchStatus::InProgress => {
if id == BranchId::Lowercase {
self.lowercase_unlocked_count()
} else {
def.levels
.iter()
.enumerate()
.filter(|(i, _)| *i <= bp.current_level)
.map(|(_, l)| l.keys.len())
.sum()
}
}
_ => 0,
}
}
/// 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()
}
/// Total keys defined in a branch for this tree configuration.
pub fn branch_total_keys_for(&self, id: BranchId) -> usize {
if id == BranchId::Lowercase {
self.primary_letters().len()
} else {
Self::branch_total_keys(id)
}
}
/// Count of unique confident keys across all branches.
pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize {
let mut keys: HashSet<char> = HashSet::new();
for &ch in ALWAYS_UNLOCKED_KEYS {
if stats.get_confidence(ch) >= 1.0 {
keys.insert(ch);
}
}
for &ch in self.primary_letters() {
if stats.get_confidence(ch) >= 1.0 {
keys.insert(ch);
}
}
for branch_def in ALL_BRANCHES {
if branch_def.id == BranchId::Lowercase {
continue;
}
for level in branch_def.levels {
for &ch in level.keys {
if stats.get_confidence(ch) >= 1.0 {
keys.insert(ch);
}
}
}
}
keys.len()
}
/// Count of confident keys in a branch.
pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize {
if id == BranchId::Lowercase {
self.primary_letters()
.iter()
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
.count()
} else {
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::*;
use crate::l10n::language_pack::language_packs;
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, 98);
}
#[test]
fn test_initial_lowercase_unlocked() {
let tree = SkillTree::default();
let keys = tree.unlocked_keys(DrillScope::Global);
assert_eq!(keys.len(), LOWERCASE_MIN_KEYS + ALWAYS_UNLOCKED_KEYS.len());
assert_eq!(&keys[2..8], &['e', 't', 'a', 'o', 'i', 'n']);
assert!(keys.contains(&SPACE));
assert!(keys.contains(&BACKSPACE));
}
#[test]
fn test_custom_primary_sequence_drives_lowercase_progression() {
let tree = SkillTree::new_with_primary_sequence(SkillTreeProgress::default(), "abcde");
let keys = tree.unlocked_keys(DrillScope::Global);
// With a shorter primary sequence, all primary letters are immediately unlocked.
assert!(keys.contains(&'a'));
assert!(keys.contains(&'e'));
assert_eq!(tree.primary_letters(), &['a', 'b', 'c', 'd', 'e']);
assert_eq!(
tree.branch_total_keys_for(BranchId::Lowercase),
tree.primary_letters().len()
);
}
#[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, None);
// Should unlock 7th key ('s')
let keys = tree.unlocked_keys(DrillScope::Global);
assert_eq!(keys.len(), 9);
assert!(keys.contains(&'s'));
}
#[test]
fn test_lowercase_completion_unlocks_branches() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make all primary letters confident.
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
// Need to repeatedly update as each unlock requires all current keys confident
for _ in 0..30 {
tree.update(&stats, None);
}
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, None);
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<char> = ('A'..='Z').collect();
make_stats_confident(&mut stats, &all_caps);
// Update multiple times for level advancement
for _ in 0..5 {
tree.update(&stats, None);
}
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 full primary-letter background + Capitals L1 (8) + Capitals L2 (10)
assert!(keys.contains(&tree.primary_letters()[0])); // primary-letter background
assert!(keys.contains(&'T')); // Capitals L1
assert!(keys.contains(&'J')); // Capitals L2 (current level)
assert!(!keys.contains(&'O')); // Capitals L3 (locked)
}
#[test]
fn test_branch_unlocked_count() {
let tree = SkillTree::default();
// Lowercase starts InProgress with LOWERCASE_MIN_KEYS
assert_eq!(
tree.branch_unlocked_count(BranchId::Lowercase),
LOWERCASE_MIN_KEYS
);
// Locked branches return 0
assert_eq!(tree.branch_unlocked_count(BranchId::Capitals), 0);
assert_eq!(tree.branch_unlocked_count(BranchId::Numbers), 0);
// InProgress non-lowercase branch
let mut tree2 = SkillTree::default();
let bp = tree2.branch_progress_mut(BranchId::Capitals);
bp.status = BranchStatus::InProgress;
bp.current_level = 1;
// Level 0 (8 keys) + Level 1 (10 keys)
assert_eq!(tree2.branch_unlocked_count(BranchId::Capitals), 18);
// Complete branch returns all keys
let mut tree3 = SkillTree::default();
tree3.branch_progress_mut(BranchId::Numbers).status = BranchStatus::Complete;
assert_eq!(tree3.branch_unlocked_count(BranchId::Numbers), 10);
}
#[test]
fn test_selectable_branches_bounds() {
use crate::ui::components::skill_tree::selectable_branches;
let branches = selectable_branches();
assert!(!branches.is_empty());
assert_eq!(branches[0], BranchId::Lowercase);
let tree = SkillTree::default();
// Accessing branch_progress for every selectable branch should not panic
for &branch_id in &branches {
let _ = tree.branch_progress(branch_id);
let _ = SkillTree::branch_total_keys(branch_id);
let _ = tree.branch_unlocked_count(branch_id);
}
// Selection at 0 and at max index should be valid
assert!(0 < branches.len());
assert!(branches.len() - 1 < branches.len());
}
#[test]
fn progression_is_monotonic_for_all_language_pack_sequences() {
for pack in language_packs() {
let mut tree = SkillTree::new_with_primary_sequence(
SkillTreeProgress::default(),
pack.primary_letter_sequence,
);
let primary = tree.primary_letters().to_vec();
assert!(
!primary.is_empty(),
"primary sequence should be non-empty for {}",
pack.language_key
);
let mut stats = KeyStatsStore::default();
let mut previous_count = tree.lowercase_unlocked_count();
assert!(
previous_count <= primary.len(),
"initial unlock count must be bounded for {}",
pack.language_key
);
// Master keys in configured sequence order and verify unlocked count never decreases.
for &ch in &primary {
make_stats_confident(&mut stats, &[ch]);
for _ in 0..3 {
tree.update(&stats, None);
let current_count = tree.lowercase_unlocked_count();
assert!(
current_count >= previous_count,
"unlock count regressed for {}: {} -> {}",
pack.language_key,
previous_count,
current_count
);
previous_count = current_count;
}
}
for _ in 0..30 {
tree.update(&stats, None);
let current_count = tree.lowercase_unlocked_count();
assert!(
current_count >= previous_count,
"unlock count regressed in completion pass for {}: {} -> {}",
pack.language_key,
previous_count,
current_count
);
previous_count = current_count;
}
assert_eq!(
tree.lowercase_unlocked_count(),
primary.len(),
"all primary letters should unlock for {}",
pack.language_key
);
assert_eq!(
*tree.branch_status(BranchId::Lowercase),
BranchStatus::Complete,
"lowercase branch should complete for {}",
pack.language_key
);
}
}
#[test]
fn test_update_returns_newly_unlocked() {
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']);
let result = tree.update(&stats, None);
// Should unlock 7th key ('s')
assert!(
result.newly_unlocked.contains(&'s'),
"newly_unlocked: {:?}",
result.newly_unlocked
);
}
#[test]
fn test_update_returns_newly_mastered() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Snapshot before any key stats are added
let before_stats = stats.clone();
// Make first 5 keys confident
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i']);
let result = tree.update(&stats, Some(&before_stats));
// The 5 keys that went from <1.0 to >=1.0 should be in newly_mastered
for &ch in &['e', 't', 'a', 'o', 'i'] {
assert!(
result.newly_mastered.contains(&ch),
"expected {} in newly_mastered: {:?}",
ch,
result.newly_mastered
);
}
}
#[test]
fn test_find_key_branch_lowercase() {
let result = find_key_branch('e');
assert!(result.is_some());
let (branch, level, pos) = result.unwrap();
assert_eq!(branch.id, BranchId::Lowercase);
assert_eq!(level.name_key, "skill_tree.level_frequency_order");
assert_eq!(pos, 1); // 'e' is first in the frequency order
}
#[test]
fn test_find_key_branch_capitals() {
let result = find_key_branch('T');
assert!(result.is_some());
let (branch, level, pos) = result.unwrap();
assert_eq!(branch.id, BranchId::Capitals);
assert_eq!(level.name_key, "skill_tree.level_common_sentence_capitals");
assert_eq!(pos, 1); // 'T' is first
}
#[test]
fn test_find_key_branch_unknown() {
assert!(find_key_branch('\x00').is_none());
}
#[test]
fn test_branches_newly_available_on_lowercase_complete() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
// Run updates to advance through progressive unlock
let mut found_available = false;
for _ in 0..30 {
let result = tree.update(&stats, None);
if !result.branches_newly_available.is_empty() {
found_available = true;
// Should contain exactly the 5 non-lowercase branches
assert_eq!(result.branches_newly_available.len(), 5);
assert!(
!result
.branches_newly_available
.contains(&BranchId::Lowercase)
);
assert!(
result
.branches_newly_available
.contains(&BranchId::Capitals)
);
assert!(result.branches_newly_available.contains(&BranchId::Numbers));
assert!(
result
.branches_newly_available
.contains(&BranchId::ProsePunctuation)
);
assert!(
result
.branches_newly_available
.contains(&BranchId::Whitespace)
);
assert!(
result
.branches_newly_available
.contains(&BranchId::CodeSymbols)
);
break;
}
}
assert!(found_available, "branches_newly_available should fire once");
// Second update should NOT have branches_newly_available
let result2 = tree.update(&stats, None);
assert!(
result2.branches_newly_available.is_empty(),
"branches_newly_available should be empty on subsequent call"
);
}
#[test]
fn test_branches_newly_completed_on_branch_complete() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Set up: capitals InProgress
tree.branch_progress_mut(BranchId::Capitals).status = BranchStatus::InProgress;
// Make all capital letters confident
let all_caps: Vec<char> = ('A'..='Z').collect();
make_stats_confident(&mut stats, &all_caps);
// Advance through levels
let mut found_complete = false;
for _ in 0..5 {
let result = tree.update(&stats, None);
if result
.branches_newly_completed
.contains(&BranchId::Capitals)
{
found_complete = true;
break;
}
}
assert!(
found_complete,
"branches_newly_completed should contain Capitals"
);
// Second update should not re-report
let result2 = tree.update(&stats, None);
assert!(
!result2
.branches_newly_completed
.contains(&BranchId::Capitals),
"should not re-report Capitals as newly completed"
);
}
#[test]
fn test_all_keys_unlocked_fires_once() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Set all branches to InProgress at last level with all keys confident
// First complete lowercase
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
for _ in 0..30 {
tree.update(&stats, None);
}
// Start all branches and make their keys confident
for &id in &[
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
] {
tree.start_branch(id);
let def = get_branch_definition(id);
for level in def.levels {
make_stats_confident(&mut stats, level.keys);
}
}
// Advance all branches through their levels
let mut found_all_unlocked = false;
for _ in 0..20 {
let result = tree.update(&stats, None);
if result.all_keys_unlocked {
found_all_unlocked = true;
break;
}
}
assert!(
found_all_unlocked,
"all_keys_unlocked should fire when last key becomes available"
);
// Subsequent call should not fire again
let result = tree.update(&stats, None);
assert!(
!result.all_keys_unlocked,
"all_keys_unlocked should not fire on subsequent calls"
);
}
#[test]
fn test_all_keys_mastered_fires_once() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make all keys across all branches confident
for branch_def in ALL_BRANCHES {
for level in branch_def.levels {
make_stats_confident(&mut stats, level.keys);
}
}
// Complete lowercase first
for _ in 0..30 {
tree.update(&stats, None);
}
// Start and advance all other branches
for &id in &[
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
] {
tree.start_branch(id);
}
let mut found_all_mastered = false;
for _ in 0..30 {
let result = tree.update(&stats, None);
if result.all_keys_mastered {
found_all_mastered = true;
break;
}
}
assert!(
found_all_mastered,
"all_keys_mastered should fire when all branches complete"
);
// Subsequent call should not fire again
let result = tree.update(&stats, None);
assert!(
!result.all_keys_mastered,
"all_keys_mastered should not fire on subsequent calls"
);
}
#[test]
fn test_branches_newly_available_only_non_lowercase() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
for _ in 0..30 {
let result = tree.update(&stats, None);
if !result.branches_newly_available.is_empty() {
for &id in &result.branches_newly_available {
assert_ne!(
id,
BranchId::Lowercase,
"branches_newly_available should not contain Lowercase"
);
}
break;
}
}
}
#[test]
fn test_branches_newly_available_canonical_order() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
let all_primary = tree.primary_letters().to_vec();
make_stats_confident(&mut stats, &all_primary);
for _ in 0..30 {
let result = tree.update(&stats, None);
if !result.branches_newly_available.is_empty() {
// Must be in canonical order: Capitals, Numbers, ProsePunctuation, Whitespace, CodeSymbols
assert_eq!(
result.branches_newly_available,
vec![
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
],
"branches_newly_available must be in canonical order"
);
break;
}
}
}
}