Key milestone overlays + keyboard diagram improvements

Also splits out a separate store for ranked stats from overall key
stats.
This commit is contained in:
2026-02-20 23:15:13 +00:00
parent 4e39e99732
commit 9e0411e1f4
12 changed files with 2185 additions and 279 deletions

View File

@@ -4,6 +4,12 @@ use serde::{Deserialize, Serialize};
use crate::engine::key_stats::KeyStatsStore;
/// Events returned by `SkillTree::update` describing what changed.
pub struct SkillTreeUpdate {
pub newly_unlocked: Vec<char>,
pub newly_mastered: Vec<char>,
}
// --- Branch ID ---
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -188,6 +194,19 @@ pub const ALL_BRANCHES: &[BranchDefinition] = &[
},
];
/// Find which branch and level a key belongs to.
/// Returns (branch_def, level_name, 1-based position in level).
pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static str, 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.name, pos + 1));
}
}
}
None
}
pub fn get_branch_definition(id: BranchId) -> &'static BranchDefinition {
ALL_BRANCHES
.iter()
@@ -487,7 +506,19 @@ impl SkillTree {
/// Update skill tree progress based on current key stats.
/// Call after updating KeyStatsStore.
pub fn update(&mut self, stats: &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();
// Update lowercase branch (progressive unlock)
self.update_lowercase(stats);
@@ -518,6 +549,34 @@ impl SkillTree {
}
self.update_branch_level(branch_def, stats);
}
// 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,
}
}
fn update_lowercase(&mut self, stats: &KeyStatsStore) {
@@ -731,7 +790,7 @@ mod tests {
// Make initial 6 keys confident
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
tree.update(&stats);
tree.update(&stats, None);
// Should unlock 7th key ('s')
let keys = tree.unlocked_keys(DrillScope::Global);
@@ -750,7 +809,7 @@ mod tests {
// Need to repeatedly update as each unlock requires all current keys confident
for _ in 0..30 {
tree.update(&stats);
tree.update(&stats, None);
}
assert_eq!(
@@ -805,7 +864,7 @@ mod tests {
// 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);
tree.update(&stats, None);
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 1);
assert_eq!(
@@ -829,7 +888,7 @@ mod tests {
// Update multiple times for level advancement
for _ in 0..5 {
tree.update(&stats);
tree.update(&stats, None);
}
assert_eq!(
@@ -968,4 +1027,69 @@ mod tests {
assert!(0 < branches.len());
assert!(branches.len() - 1 < branches.len());
}
#[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_name, pos) = result.unwrap();
assert_eq!(branch.id, BranchId::Lowercase);
assert_eq!(level_name, "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_name, pos) = result.unwrap();
assert_eq!(branch.id, BranchId::Capitals);
assert_eq!(level_name, "Common Sentence Capitals");
assert_eq!(pos, 1); // 'T' is first
}
#[test]
fn test_find_key_branch_unknown() {
assert!(find_key_branch('\x00').is_none());
}
}