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