Skip to main content

very_normal/
gate.rs

1//! The mastery gate: what has to be true before climbing a stage.
2
3use crate::layout;
4use crate::store::Progress;
5
6/// Real text has to get back to this share of your baseline speed.
7pub const SPEED_SHARE: f64 = 0.9;
8pub const WORDS_ACCURACY: f64 = 97.0;
9/// How many of the last `WORDS_WINDOW` new-keys runs must reach `WORDS_ACCURACY`.
10pub const WORDS_NEEDED: usize = 3;
11pub const WORDS_WINDOW: usize = 5;
12pub const REAL_ACCURACY: f64 = 95.0;
13
14/// The manual boxes, and the key that ticks each one in the TUI.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Tick {
17    Kept,
18    Vim,
19    Workday,
20}
21
22impl Tick {
23    pub fn key(self) -> char {
24        match self {
25            Tick::Kept => 'k',
26            Tick::Vim => 'v',
27            Tick::Workday => 'w',
28        }
29    }
30
31    pub fn toggle(self, progress: &mut Progress, stage: usize) {
32        let m = progress.manual_mut(stage);
33        match self {
34            Tick::Kept => m.oryx = !m.oryx,
35            Tick::Vim => m.vim = !m.vim,
36            Tick::Workday => m.workday = !m.workday,
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq)]
42pub struct Item {
43    pub label: String,
44    pub ok: bool,
45    pub detail: String,
46    pub tick: Option<Tick>,
47}
48
49pub fn items(progress: &Progress, stage: usize) -> Vec<Item> {
50    let manual = progress.manual(stage);
51    if stage == 0 {
52        let n = progress.runs_for(0, "real").len();
53        let detail = match (n, progress.baseline()) {
54            (0, Some(b)) => format!("Typed in: {b:.0} WPM"),
55            (0, None) => "Do three real-text runs, or run `very-normal baseline <wpm>`".into(),
56            (n, Some(b)) => format!("{n} run(s), averaging {b:.0} WPM"),
57            (n, None) => format!("{n} run(s)"),
58        };
59        return vec![
60            Item {
61                label: "Planck setup kept safe (old revision or firmware file)".into(),
62                ok: manual.oryx,
63                detail: String::new(),
64                tick: Some(Tick::Kept),
65            },
66            Item {
67                label: "Baseline recorded".into(),
68                ok: n >= 3 || manual.wpm > 0.0,
69                detail,
70                tick: None,
71            },
72        ];
73    }
74
75    let words = progress.runs_for(stage, "words");
76    let recent: Vec<_> = words.iter().rev().take(WORDS_WINDOW).collect();
77    let hits = words_hits(progress, stage);
78    let words_ok = hits >= WORDS_NEEDED;
79    let words_detail = if recent.is_empty() {
80        "No new-keys runs yet".into()
81    } else {
82        format!(
83            "{} of {WORDS_NEEDED} so far. Recent: {}",
84            hits.min(WORDS_NEEDED),
85            recent
86                .iter()
87                .rev()
88                .map(|r| format!("{:.1}%", r.acc))
89                .collect::<Vec<_>>()
90                .join(", ")
91        )
92    };
93
94    let baseline = progress.baseline();
95    let real = progress.runs_for(stage, "real");
96    let best = real
97        .iter()
98        .rev()
99        .take(5)
100        .filter(|r| r.acc >= REAL_ACCURACY)
101        .max_by(|a, b| a.wpm.total_cmp(&b.wpm));
102    let (real_ok, real_detail) = match baseline {
103        None => (false, "Record a baseline in stage 0 first".to_string()),
104        Some(b) => {
105            let target = b * SPEED_SHARE;
106            let ok = best.is_some_and(|r| r.wpm >= target);
107            let best = best.map_or("no qualifying run yet".to_string(), |r| {
108                format!("best recent {:.0} WPM at {:.0}%", r.wpm, r.acc)
109            });
110            (ok, format!("Target {target:.0} WPM; {best}"))
111        }
112    };
113
114    vec![
115        Item {
116            label: format!(
117                "New keys at {WORDS_ACCURACY:.0}%+ in {WORDS_NEEDED} of your last {WORDS_WINDOW} runs"
118            ),
119            ok: words_ok,
120            detail: words_detail,
121            tick: None,
122        },
123        Item {
124            label: format!(
125                "Real text at {:.0}% of baseline, {REAL_ACCURACY:.0}%+ accuracy",
126                SPEED_SHARE * 100.0
127            ),
128            ok: real_ok,
129            detail: real_detail,
130            tick: None,
131        },
132        Item {
133            label: format!(
134                "Used {} in real editing without fumbling",
135                layout::vim(stage).join(" ")
136            ),
137            ok: manual.vim,
138            detail: String::new(),
139            tick: Some(Tick::Vim),
140        },
141        Item {
142            label: "A full workday without thinking about the moved keys".into(),
143            ok: manual.workday,
144            detail: String::new(),
145            tick: Some(Tick::Workday),
146        },
147    ]
148}
149
150/// How many of the last few new-keys runs reached the accuracy bar.
151pub fn words_hits(progress: &Progress, stage: usize) -> usize {
152    progress
153        .runs_for(stage, "words")
154        .iter()
155        .rev()
156        .take(WORDS_WINDOW)
157        .filter(|r| r.acc >= WORDS_ACCURACY)
158        .count()
159}
160
161pub fn passed(progress: &Progress, stage: usize) -> bool {
162    items(progress, stage).iter().all(|i| i.ok)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::store::Run;
169
170    fn run(stage: usize, mode: &str, wpm: f64, acc: f64) -> Run {
171        Run {
172            t: 0,
173            stage,
174            mode: mode.into(),
175            wpm,
176            acc,
177            secs: 60.0,
178            ..Run::default()
179        }
180    }
181
182    #[test]
183    fn stage_zero_passes_with_a_typed_baseline_and_the_box() {
184        let mut p = Progress::default();
185        assert!(!passed(&p, 0));
186        p.manual_mut(0).wpm = 35.0;
187        Tick::Kept.toggle(&mut p, 0);
188        assert!(passed(&p, 0));
189    }
190
191    #[test]
192    fn stage_one_needs_three_accurate_runs_speed_and_both_boxes() {
193        let mut p = Progress::default();
194        p.manual_mut(0).wpm = 30.0;
195        for acc in [98.0, 99.0, 97.5] {
196            p.runs.push(run(1, "words", 20.0, acc));
197        }
198        p.runs.push(run(1, "real", 28.0, 96.0));
199        assert!(!passed(&p, 1));
200        Tick::Vim.toggle(&mut p, 1);
201        Tick::Workday.toggle(&mut p, 1);
202        assert!(passed(&p, 1));
203
204        p.runs.push(run(1, "words", 25.0, 90.0));
205        p.runs.push(run(1, "words", 25.0, 91.0));
206        assert!(passed(&p, 1), "a couple of wobbly runs don't wipe progress");
207        p.runs.push(run(1, "words", 25.0, 92.0));
208        assert!(!passed(&p, 1), "but the good runs have to be recent");
209    }
210
211    #[test]
212    fn slow_or_sloppy_real_text_does_not_count() {
213        let mut p = Progress::default();
214        p.manual_mut(0).wpm = 30.0;
215        p.runs.push(run(1, "real", 26.0, 99.0));
216        p.runs.push(run(1, "real", 40.0, 90.0));
217        assert!(!items(&p, 1)[1].ok);
218    }
219}