Skip to main content

very_normal/
drill.rs

1//! The drills, and the typing engine that scores them.
2
3use std::collections::BTreeMap;
4use std::time::{Duration, Instant};
5
6use crate::layout;
7use crate::store::{LetterStat, Progress};
8
9/// How long a pause before the next key lights up on the map.
10pub const HINT_AFTER: Duration = Duration::from_millis(1200);
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Mode {
14    Recall,
15    Words,
16    Review,
17    Real,
18    Links,
19    Mirror,
20}
21
22impl Mode {
23    pub const ALL: [Mode; 6] = [
24        Mode::Recall,
25        Mode::Words,
26        Mode::Review,
27        Mode::Real,
28        Mode::Links,
29        Mode::Mirror,
30    ];
31
32    /// The name stored with each run, shared with the web tutor.
33    pub fn id(self) -> &'static str {
34        match self {
35            Mode::Recall => "recall",
36            Mode::Words => "words",
37            Mode::Review => "review",
38            Mode::Real => "real",
39            Mode::Links => "links",
40            Mode::Mirror => "mirror",
41        }
42    }
43
44    pub fn label(self) -> &'static str {
45        match self {
46            Mode::Recall => "key recall",
47            Mode::Words => "new keys",
48            Mode::Review => "review",
49            Mode::Real => "real text 60s",
50            Mode::Links => "markdown links",
51            Mode::Mirror => "mirror (bonus)",
52        }
53    }
54
55    pub fn label_for(id: &str) -> &'static str {
56        Mode::ALL
57            .iter()
58            .find(|m| m.id() == id)
59            .map(|m| m.label())
60            .unwrap_or("other")
61    }
62
63    pub fn time_limit(self) -> Option<Duration> {
64        (self == Mode::Real).then(|| Duration::from_secs(60))
65    }
66
67    pub fn describe(self, stage: usize) -> String {
68        match self {
69            Mode::Recall if stage == 0 => "Single home-row keys. Pick a stage to drill its moved keys.".into(),
70            Mode::Recall => format!(
71                "One key at a time from this stage's moves: {}. Keys you miss or hesitate on come back more often.",
72                layout::focus(stage).iter().map(char::to_string).collect::<Vec<_>>().join(" ")
73            ),
74            Mode::Words if stage == 0 => "Common words from your own typing.".into(),
75            Mode::Words => "30 words you actually type, heavy in this stage's moved keys. The gate counts this one.".into(),
76            Mode::Review if stage < 2 => "Same as new keys until stage 2.".into(),
77            Mode::Review => format!("Words from every stage so far (1 to {stage}), so earlier swaps stay sharp."),
78            Mode::Real if stage == 0 => "60 seconds of your most common words. Three of these set your baseline.".into(),
79            Mode::Real => "60 seconds of your most common words. The gate compares this with your baseline.".into(),
80            Mode::Links => "[text](link) shapes for your symbol layers. The gate ignores it.".into(),
81            Mode::Mirror => "Left hand only. Hold the mirror key for right-hand letters; the second letter on each left key is what it types then. The gate ignores it.".into(),
82        }
83    }
84}
85
86/// A small xorshift generator: plenty for shuffling drill words.
87pub struct Rng(u64);
88
89impl Rng {
90    pub fn new() -> Rng {
91        let seed = std::time::SystemTime::now()
92            .duration_since(std::time::UNIX_EPOCH)
93            .map(|d| d.as_nanos() as u64)
94            .unwrap_or(0x9e37_79b9_7f4a_7c15);
95        Rng::seeded(seed)
96    }
97
98    pub fn seeded(seed: u64) -> Rng {
99        Rng(seed | 1)
100    }
101
102    pub fn next_u64(&mut self) -> u64 {
103        let mut x = self.0;
104        x ^= x << 13;
105        x ^= x >> 7;
106        x ^= x << 17;
107        self.0 = x;
108        x
109    }
110
111    /// A float in [0, 1).
112    pub fn unit(&mut self) -> f64 {
113        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
114    }
115
116    pub fn below(&mut self, n: usize) -> usize {
117        (self.unit() * n as f64) as usize
118    }
119
120    pub fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
121        &items[self.below(items.len())]
122    }
123}
124
125impl Default for Rng {
126    fn default() -> Self {
127        Rng::new()
128    }
129}
130
131/// The stage's own word list, or common words that use its moved keys.
132fn stage_words(stage: usize) -> Vec<String> {
133    let own = layout::words(stage);
134    if stage > 0 && !own.is_empty() {
135        return own.to_vec();
136    }
137    let focus = layout::focus(stage);
138    let common = layout::common_words();
139    let picked: Vec<String> = common
140        .iter()
141        .filter(|w| w.chars().any(|c| focus.contains(&c)))
142        .take(80)
143        .cloned()
144        .collect();
145    if picked.len() >= 10 {
146        picked
147    } else {
148        common.iter().take(200).cloned().collect()
149    }
150}
151
152fn words_from(pool: &[String], n: usize, rng: &mut Rng) -> String {
153    (0..n)
154        .map(|_| rng.pick(pool).as_str())
155        .collect::<Vec<_>>()
156        .join(" ")
157}
158
159/// Keys you miss or are slow on get picked more often.
160fn weighted_char(stage: usize, progress: &Progress, rng: &mut Rng) -> char {
161    let mut focus = layout::focus(stage);
162    if focus.is_empty() {
163        focus = "asdfjkl;".chars().collect();
164    }
165    let stats = progress.letters(stage);
166    let weights: Vec<f64> = focus
167        .iter()
168        .map(|c| match stats.get(&c.to_string()) {
169            Some(s) if s.n > 0 => {
170                1.0 + 12.0 * (s.miss as f64 / s.n as f64) + s.ms / s.n as f64 / 350.0
171            }
172            _ => 3.0,
173        })
174        .collect();
175    let mut t = weights.iter().sum::<f64>() * rng.unit();
176    for (c, w) in focus.iter().zip(&weights) {
177        t -= w;
178        if t <= 0.0 {
179            return *c;
180        }
181    }
182    focus[0]
183}
184
185pub fn build(mode: Mode, stage: usize, progress: &Progress, rng: &mut Rng) -> String {
186    let common = layout::common_words();
187    match mode {
188        Mode::Recall => {
189            let several = layout::focus(stage).len() > 1;
190            let mut out = String::new();
191            let mut prev = None;
192            while out.chars().count() < 40 {
193                let c = weighted_char(stage, progress, rng);
194                if several && Some(c) == prev {
195                    continue;
196                }
197                out.push(c);
198                prev = Some(c);
199            }
200            out
201        }
202        Mode::Words => words_from(&stage_words(stage), 30, rng),
203        Mode::Review => {
204            let pool: Vec<String> = if stage < 2 {
205                stage_words(stage)
206            } else {
207                (1..=stage).flat_map(stage_words).collect()
208            };
209            words_from(&pool, 30, rng)
210        }
211        Mode::Real => (0..260)
212            .map(|_| common[(rng.unit().powf(2.2) * common.len() as f64) as usize].as_str())
213            .collect::<Vec<_>>()
214            .join(" "),
215        Mode::Links => {
216            let top = &common[..300.min(common.len())];
217            (0..10)
218                .map(|_| {
219                    let (a, b, c) = (rng.pick(top), rng.pick(top), rng.pick(top));
220                    if rng.unit() < 0.5 {
221                        format!("see [{a} {b}]({c}.md)")
222                    } else {
223                        format!("[{a}](https://{b}.dev/{c})")
224                    }
225                })
226                .collect::<Vec<_>>()
227                .join(" ")
228        }
229        Mode::Mirror => {
230            let pool: Vec<String> = common
231                .iter()
232                .take(150)
233                .filter(|w| w.len() >= 2 && w.chars().all(|c| c.is_ascii_lowercase()))
234                .cloned()
235                .collect();
236            words_from(&pool, 20, rng)
237        }
238    }
239}
240
241pub struct Drill {
242    pub mode: Mode,
243    pub stage: usize,
244    pub text: Vec<char>,
245    pub pos: usize,
246    pub keys: u64,
247    pub misses: u64,
248    pub error: bool,
249    pub done: bool,
250    started: Option<Instant>,
251    last_key: Option<Instant>,
252    last_progress: Instant,
253    finished_at: Option<Instant>,
254    log: BTreeMap<String, LetterStat>,
255}
256
257impl Drill {
258    pub fn new(mode: Mode, stage: usize, text: String, now: Instant) -> Drill {
259        Drill {
260            mode,
261            stage,
262            text: text.chars().collect(),
263            pos: 0,
264            keys: 0,
265            misses: 0,
266            error: false,
267            done: false,
268            started: None,
269            last_key: None,
270            last_progress: now,
271            finished_at: None,
272            log: BTreeMap::new(),
273        }
274    }
275
276    pub fn expected(&self) -> Option<char> {
277        (!self.done)
278            .then(|| self.text.get(self.pos).copied())
279            .flatten()
280    }
281
282    pub fn started(&self) -> bool {
283        self.started.is_some()
284    }
285
286    /// Restarts the hint clock, for when the drill gets focus.
287    pub fn touch(&mut self, now: Instant) {
288        self.last_progress = now;
289    }
290
291    /// Wrong keys don't advance; they count as a miss on the key you wanted.
292    pub fn press(&mut self, c: char, now: Instant) {
293        if self.done {
294            return;
295        }
296        let Some(want) = self.expected() else { return };
297        if self.started.is_none() {
298            self.started = Some(now);
299            self.last_key = Some(now);
300        }
301        self.keys += 1;
302        let stat = self.log.entry(want.to_string()).or_default();
303        if c == want {
304            if want != ' ' && self.pos > 0 {
305                let gap = now - self.last_key.unwrap_or(now);
306                stat.n += 1;
307                stat.ms += gap.min(Duration::from_secs(3)).as_secs_f64() * 1000.0;
308            }
309            self.pos += 1;
310            self.last_key = Some(now);
311            self.last_progress = now;
312            self.error = false;
313            if self.pos >= self.text.len() {
314                self.finish(now);
315            }
316        } else {
317            self.misses += 1;
318            stat.miss += 1;
319            self.error = true;
320        }
321    }
322
323    /// Ends a timed drill once its time is up.
324    pub fn tick(&mut self, now: Instant) {
325        if let (Some(limit), false) = (self.mode.time_limit(), self.done)
326            && self.elapsed(now) >= limit
327        {
328            self.finish(now);
329        }
330    }
331
332    fn finish(&mut self, now: Instant) {
333        self.done = true;
334        self.finished_at = Some(now);
335    }
336
337    pub fn elapsed(&self, now: Instant) -> Duration {
338        match self.started {
339            Some(s) => self.finished_at.unwrap_or(now) - s,
340            None => Duration::ZERO,
341        }
342    }
343
344    pub fn remaining(&self, now: Instant) -> Option<Duration> {
345        self.mode
346            .time_limit()
347            .map(|l| l.saturating_sub(self.elapsed(now)))
348    }
349
350    pub fn wpm(&self, now: Instant) -> f64 {
351        let minutes = self.elapsed(now).as_secs_f64().max(1.0) / 60.0;
352        self.pos as f64 / 5.0 / minutes
353    }
354
355    pub fn accuracy(&self) -> f64 {
356        if self.keys == 0 {
357            100.0
358        } else {
359            100.0 * (self.keys - self.misses) as f64 / self.keys as f64
360        }
361    }
362
363    pub fn hesitating(&self, now: Instant) -> bool {
364        !self.done && now - self.last_progress >= HINT_AFTER
365    }
366
367    pub fn letters(&self) -> &BTreeMap<String, LetterStat> {
368        &self.log
369    }
370
371    /// The slowest keys of this drill, for the recall summary.
372    pub fn slowest(&self, n: usize) -> Vec<(char, f64, u64)> {
373        let mut v: Vec<(char, f64, u64)> = self
374            .log
375            .iter()
376            .filter_map(|(c, s)| {
377                let ch = c.chars().next()?;
378                Some((ch, s.average_ms()?, s.miss))
379            })
380            .collect();
381        v.sort_by(|a, b| b.1.total_cmp(&a.1));
382        v.truncate(n);
383        v
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn at(start: Instant, ms: u64) -> Instant {
392        start + Duration::from_millis(ms)
393    }
394
395    #[test]
396    fn wrong_keys_count_as_misses_and_do_not_advance() {
397        let t0 = Instant::now();
398        let mut d = Drill::new(Mode::Words, 1, "ab".into(), t0);
399        d.press('x', at(t0, 0));
400        assert_eq!((d.pos, d.misses, d.error), (0, 1, true));
401        d.press('a', at(t0, 100));
402        d.press('b', at(t0, 400));
403        assert!(d.done);
404        assert_eq!(d.keys, 3);
405        assert!((d.accuracy() - 66.666).abs() < 0.01);
406        assert_eq!(d.letters()["b"].ms, 300.0);
407        assert_eq!(d.letters()["a"].miss, 1);
408    }
409
410    #[test]
411    fn timed_drills_stop_at_the_limit() {
412        let t0 = Instant::now();
413        let mut d = Drill::new(Mode::Real, 0, "a".repeat(500), t0);
414        d.press('a', t0);
415        d.tick(at(t0, 59_000));
416        assert!(!d.done);
417        d.tick(at(t0, 60_000));
418        assert!(d.done);
419        assert_eq!(d.elapsed(at(t0, 90_000)), Duration::from_secs(60));
420    }
421
422    #[test]
423    fn wpm_is_characters_over_five_per_minute() {
424        let t0 = Instant::now();
425        let mut d = Drill::new(Mode::Words, 1, "hello world".into(), t0);
426        for (i, c) in "hello world".chars().enumerate() {
427            d.press(c, at(t0, i as u64 * 1200));
428        }
429        // 11 characters over 12 seconds
430        assert!((d.wpm(at(t0, 99_000)) - 11.0).abs() < 0.01);
431    }
432
433    #[test]
434    fn the_hint_shows_after_a_pause() {
435        let t0 = Instant::now();
436        let d = Drill::new(Mode::Recall, 1, "e".into(), t0);
437        assert!(!d.hesitating(at(t0, 1000)));
438        assert!(d.hesitating(at(t0, 1300)));
439    }
440
441    #[test]
442    fn recall_only_uses_the_stage_keys() {
443        let mut rng = Rng::seeded(42);
444        let text = build(Mode::Recall, 3, &Progress::default(), &mut rng);
445        let focus = layout::focus(3);
446        assert_eq!(text.chars().count(), 40);
447        assert!(text.chars().all(|c| focus.contains(&c)));
448        assert!(text.chars().zip(text.chars().skip(1)).all(|(a, b)| a != b));
449    }
450
451    #[test]
452    fn every_mode_builds_text() {
453        let mut rng = Rng::seeded(7);
454        for stage in 0..=layout::last() {
455            for mode in Mode::ALL {
456                assert!(!build(mode, stage, &Progress::default(), &mut rng).is_empty());
457            }
458        }
459    }
460}