Skip to main content

lattice_host/tutor/
session.rs

1//! T.1 — Interactive tutor data types.
2//!
3//! `TutorExercise` + `SuccessCondition` + `TutorSession` are pure data;
4//! no dependency on the mode infrastructure.  `TutorMode` (T.3) wraps
5//! `TutorSession` in a `BufferLocal` and drives the UI.
6
7use serde::Deserialize;
8
9pub const MAX_LIVES: u8 = 3;
10
11/// High-level game state for the tutor session.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum TutorGameState {
14    /// Normal play: lives > 0, exercises remaining.
15    Active,
16    /// Player ran out of lives on the current exercise.
17    GameOver,
18    /// All lessons in the build are complete.
19    AllComplete,
20}
21
22// ---- Success conditions -----------------------------------------------
23
24/// How the tutor decides an exercise is complete.
25#[derive(Debug, Clone, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum SuccessCondition {
28    /// Any edit to the anchor line counts as success.
29    TextChanged,
30    /// The anchor line must now contain this substring.
31    TextContains(String),
32    /// The anchor line must no longer contain this substring.
33    TextNotContains(String),
34    /// The anchor line must equal this string exactly.
35    TextEquals(String),
36    /// Observational exercise: the user must run `:tutor-next` explicitly.
37    /// `is_met` always returns `false`; the action handler bypasses it.
38    ManualAdvance,
39}
40
41impl SuccessCondition {
42    /// Pure evaluation: `initial` is the anchor line at load time,
43    /// `current` is its text after edits.
44    pub fn is_met(&self, initial: &str, current: &str) -> bool {
45        match self {
46            Self::TextChanged => current != initial,
47            Self::TextContains(pat) => current.contains(pat.as_str()),
48            Self::TextNotContains(pat) => !current.contains(pat.as_str()),
49            Self::TextEquals(expected) => current == expected.as_str(),
50            Self::ManualAdvance => false,
51        }
52    }
53}
54
55// ---- Exercise descriptor ----------------------------------------------
56
57/// One practice exercise in a tutor lesson.
58#[derive(Debug, Clone, Deserialize)]
59pub struct TutorExercise {
60    /// Short identifier, e.g. `"2.3.1"`.
61    pub id: String,
62    /// One-line description shown in the TutorMode headerline.
63    pub description: String,
64    /// Exact text of the practice line in the lesson at load time.
65    /// `TutorSession::load` scans the lesson text to resolve this to a
66    /// line index.  The match is whitespace-trimmed on both sides.
67    pub anchor: String,
68    /// What counts as successful completion.
69    pub success: SuccessCondition,
70    /// Shown in the headerline after 3 unsuccessful edit attempts.
71    pub hint: String,
72}
73
74// ---- Sidecar TOML wire format -----------------------------------------
75
76/// Top-level shape of `lesson-N.exercises.toml`.
77#[derive(Debug, Deserialize)]
78struct ExerciseSidecar {
79    exercises: Vec<TutorExercise>,
80}
81
82// ---- Session ----------------------------------------------------------
83
84/// Per-tutor-buffer state.  Stored as a `BufferLocal` by `TutorMode`.
85#[derive(Debug, Clone)]
86pub struct TutorSession {
87    pub lesson: u32,
88    /// Total lesson count in this build (5 at launch).
89    pub total_lessons: u32,
90    pub exercises: Vec<TutorExercise>,
91    /// Index of the current exercise.
92    pub current: usize,
93    /// Failed-attempt count since the last advance/retreat.
94    /// Used to gate hint display (shows after ≥ 2).
95    pub attempt_count: usize,
96    /// Resolved line index in the buffer for each exercise's anchor.
97    /// Parallel to `exercises`.
98    pub anchor_lines: Vec<usize>,
99    /// Text snapshot of each anchor line as of session load.
100    /// Parallel to `exercises`.
101    pub initial_texts: Vec<String>,
102    /// Last buffer version seen by the post-dispatch watcher.
103    pub last_version: u64,
104    /// Lives remaining for the current exercise.
105    pub lives: u8,
106    /// Cumulative score across all passed exercises this session.
107    pub score: u32,
108    /// Wall-clock start time for the current exercise; used for speed bonuses.
109    pub exercise_started_at: Option<std::time::Instant>,
110    /// High-level game state.
111    pub state: TutorGameState,
112    /// All-time high score for this lesson loaded from disk at session
113    /// start.  Shown in the HUD as `HI:`.
114    pub high_score: u32,
115}
116
117impl TutorSession {
118    /// Build a `TutorSession` by parsing the exercise sidecar and
119    /// scanning the lesson text for each anchor.
120    ///
121    /// Returns `Err` (human-readable) if:
122    /// - the sidecar TOML is malformed, or
123    /// - any anchor text is not found in `lesson_text`.
124    pub fn load(
125        lesson: u32,
126        total_lessons: u32,
127        lesson_text: &str,
128        exercises_toml: &str,
129    ) -> Result<Self, String> {
130        let sidecar: ExerciseSidecar = toml::from_str(exercises_toml)
131            .map_err(|e| format!("tutor: lesson {lesson} exercises malformed: {e}"))?;
132
133        let lines: Vec<&str> = lesson_text.lines().collect();
134
135        let mut anchor_lines = Vec::with_capacity(sidecar.exercises.len());
136        let mut initial_texts = Vec::with_capacity(sidecar.exercises.len());
137
138        for ex in &sidecar.exercises {
139            let idx = lines
140                .iter()
141                .position(|l| l.trim() == ex.anchor.trim())
142                .ok_or_else(|| {
143                    format!(
144                        "tutor: lesson {lesson} exercise '{}': anchor not found: {:?}",
145                        ex.id, ex.anchor
146                    )
147                })?;
148            anchor_lines.push(idx);
149            initial_texts.push(lines[idx].to_owned());
150        }
151
152        Ok(Self {
153            lesson,
154            total_lessons,
155            exercises: sidecar.exercises,
156            current: 0,
157            attempt_count: 0,
158            anchor_lines,
159            initial_texts,
160            last_version: 0,
161            lives: MAX_LIVES,
162            score: 0,
163            exercise_started_at: Some(std::time::Instant::now()),
164            state: TutorGameState::Active,
165            high_score: 0,
166        })
167    }
168
169    /// `true` when all exercises in this lesson have been advanced past.
170    pub fn is_complete(&self) -> bool {
171        self.current >= self.exercises.len()
172    }
173
174    /// The current exercise, or `None` if the lesson is complete.
175    pub fn current_exercise(&self) -> Option<&TutorExercise> {
176        self.exercises.get(self.current)
177    }
178
179    /// Auto-detect pass/fail against live buffer lines.  Awards score
180    /// on pass (base + first-try bonus + speed bonus).  Increments
181    /// `attempt_count` on failure.  Does NOT drain lives — that only
182    /// happens on explicit `<CR>` presses via `drain_life`.
183    ///
184    /// Returns `false` when the session is not `Active` or the lesson
185    /// is already complete.
186    pub fn check(&mut self, lines: &[&str]) -> bool {
187        if self.state != TutorGameState::Active {
188            return false;
189        }
190        let Some(ex) = self.exercises.get(self.current) else {
191            return false;
192        };
193        let anchor_idx = self.anchor_lines[self.current];
194        let current_text = lines.get(anchor_idx).copied().unwrap_or("");
195        let initial_text = self.initial_texts[self.current].as_str();
196
197        if ex.success.is_met(initial_text, current_text) {
198            let base = 100u32;
199            let first_try = if self.attempt_count == 0 { 50 } else { 0 };
200            let speed = self
201                .exercise_started_at
202                .map(|t| {
203                    let s = t.elapsed().as_secs();
204                    if s < 10 {
205                        100
206                    } else if s < 30 {
207                        50
208                    } else if s < 60 {
209                        25
210                    } else {
211                        0
212                    }
213                })
214                .unwrap_or(0);
215            self.score = self.score.saturating_add(base + first_try + speed);
216            true
217        } else {
218            self.attempt_count += 1;
219            false
220        }
221    }
222
223    /// Peek: is the success condition currently met?  Pure read — no
224    /// side effects.  Used by `do_tutor_advance` to decide whether an
225    /// explicit `<CR>` press is a valid advance or a penalised miss.
226    pub fn is_condition_met(&self, lines: &[&str]) -> bool {
227        let Some(ex) = self.exercises.get(self.current) else {
228            return false;
229        };
230        let anchor_idx = self.anchor_lines[self.current];
231        let current_text = lines.get(anchor_idx).copied().unwrap_or("");
232        let initial_text = self.initial_texts[self.current].as_str();
233        ex.success.is_met(initial_text, current_text)
234    }
235
236    /// Drain one life on an explicit wrong attempt (`<CR>` press when
237    /// the condition is not met).  Increments `attempt_count` and
238    /// transitions to `GameOver` when lives reach zero.
239    pub fn drain_life(&mut self) {
240        self.attempt_count += 1;
241        self.lives = self.lives.saturating_sub(1);
242        if self.lives == 0 {
243            self.state = TutorGameState::GameOver;
244        }
245    }
246
247    /// Advance to the next exercise; resets lives, attempt_count, and
248    /// restarts the exercise timer.  Returns the new current exercise,
249    /// or `None` if the lesson is now complete.
250    pub fn advance(&mut self) -> Option<&TutorExercise> {
251        self.current += 1;
252        self.attempt_count = 0;
253        self.lives = MAX_LIVES;
254        self.state = TutorGameState::Active;
255        self.exercise_started_at = Some(std::time::Instant::now());
256        self.exercises.get(self.current)
257    }
258
259    /// Return to the previous exercise; resets lives, attempt_count,
260    /// and clears any GameOver state.
261    pub fn retreat(&mut self) {
262        if self.current > 0 {
263            self.current -= 1;
264        }
265        self.attempt_count = 0;
266        self.lives = MAX_LIVES;
267        self.state = TutorGameState::Active;
268        self.exercise_started_at = Some(std::time::Instant::now());
269    }
270}
271
272// ---- BufferLocal impl -------------------------------------------------
273
274impl lattice_mode::BufferLocal for TutorSession {
275    const NAME: &'static str = "tutor-mode.session";
276    const DOC: &'static str = "Active tutor session: current lesson, exercise index, \
277         anchor line positions, attempt count.";
278    const OWNER_MODE: &'static str = "tutor-mode";
279
280    fn describe(&self) -> String {
281        if self.is_complete() {
282            format!("lesson {} complete", self.lesson)
283        } else {
284            format!(
285                "lesson {}/{} exercise {}/{}",
286                self.lesson,
287                self.total_lessons,
288                self.current + 1,
289                self.exercises.len()
290            )
291        }
292    }
293}
294
295// ---- Tests ------------------------------------------------------------
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    const TOTAL: u32 = 7;
302
303    // T.2: verify that all shipped sidecar files parse correctly and
304    // that every anchor text is present in the matching lesson file.
305    macro_rules! sidecar_roundtrip {
306        ($name:ident, $n:literal) => {
307            #[test]
308            fn $name() {
309                let lesson_text =
310                    include_str!(concat!("../../../../docs/user/tutor/lesson-", $n, ".md"));
311                let exercises_toml = include_str!(concat!(
312                    "../../../../docs/user/tutor/lesson-",
313                    $n,
314                    ".exercises.toml"
315                ));
316                TutorSession::load($n, TOTAL, lesson_text, exercises_toml).expect(concat!(
317                    "lesson-",
318                    $n,
319                    " sidecar should parse + all anchors found"
320                ));
321            }
322        };
323    }
324
325    sidecar_roundtrip!(all_anchors_found_lesson1, 1);
326    sidecar_roundtrip!(all_anchors_found_lesson2, 2);
327    sidecar_roundtrip!(all_anchors_found_lesson3, 3);
328    sidecar_roundtrip!(all_anchors_found_lesson4, 4);
329    sidecar_roundtrip!(all_anchors_found_lesson5, 5);
330    sidecar_roundtrip!(all_anchors_found_lesson6, 6);
331    sidecar_roundtrip!(all_anchors_found_lesson7, 7);
332
333    fn lesson_text() -> &'static str {
334        "Header line\n\
335         Some explanation text.\n\
336         --->  The quick brown fox jumps over the lazy dog.\n\
337         More explanation.\n\
338         --->  result = compute(some, arguments, here) + offset;\n"
339    }
340
341    fn exercises_toml() -> &'static str {
342        r#"
343[[exercises]]
344id          = "2.1"
345description = "Delete a word"
346anchor      = "--->  The quick brown fox jumps over the lazy dog."
347success     = "text_changed"
348hint        = "type d i w"
349
350[[exercises]]
351id          = "2.2"
352description = "Delete function args"
353anchor      = "--->  result = compute(some, arguments, here) + offset;"
354success     = { text_not_contains = "some, arguments, here" }
355hint        = "type d a ("
356"#
357    }
358
359    #[test]
360    fn load_resolves_anchor_lines() {
361        let s = TutorSession::load(2, TOTAL, lesson_text(), exercises_toml()).unwrap();
362        assert_eq!(s.anchor_lines[0], 2);
363        assert_eq!(s.anchor_lines[1], 4);
364        assert_eq!(
365            s.initial_texts[0],
366            "--->  The quick brown fox jumps over the lazy dog."
367        );
368    }
369
370    #[test]
371    fn load_rejects_missing_anchor() {
372        let bad = r#"
373[[exercises]]
374id = "x"
375description = "test"
376anchor = "THIS LINE DOES NOT EXIST IN THE LESSON"
377success = "text_changed"
378hint = "n/a"
379"#;
380        assert!(TutorSession::load(2, TOTAL, lesson_text(), bad).is_err());
381    }
382
383    #[test]
384    fn success_text_changed() {
385        let c = SuccessCondition::TextChanged;
386        assert!(!c.is_met("original", "original"));
387        assert!(c.is_met("original", "modified"));
388    }
389
390    #[test]
391    fn success_text_contains() {
392        let c = SuccessCondition::TextContains("dog".into());
393        assert!(c.is_met("", "the lazy dog"));
394        assert!(!c.is_met("", "the lazy cat"));
395    }
396
397    #[test]
398    fn success_text_not_contains() {
399        let c = SuccessCondition::TextNotContains("REMOVE".into());
400        assert!(c.is_met("has REMOVE", "gone"));
401        assert!(!c.is_met("has REMOVE", "still REMOVE here"));
402    }
403
404    #[test]
405    fn success_text_equals() {
406        let c = SuccessCondition::TextEquals("exact".into());
407        assert!(c.is_met("", "exact"));
408        assert!(!c.is_met("", "exact "));
409    }
410
411    #[test]
412    fn success_manual_advance_never_auto() {
413        let c = SuccessCondition::ManualAdvance;
414        assert!(!c.is_met("", ""));
415        assert!(!c.is_met("anything", "completely different"));
416    }
417
418    #[test]
419    fn check_increments_attempt_count_on_failure() {
420        let mut s = TutorSession::load(2, TOTAL, lesson_text(), exercises_toml()).unwrap();
421        let lines: Vec<&str> = lesson_text().lines().collect();
422        assert!(!s.check(&lines));
423        assert_eq!(s.attempt_count, 1);
424        assert!(!s.check(&lines));
425        assert_eq!(s.attempt_count, 2);
426    }
427
428    #[test]
429    fn check_succeeds_when_anchor_line_changed() {
430        let mut s = TutorSession::load(2, TOTAL, lesson_text(), exercises_toml()).unwrap();
431        let edited = "Header line\n\
432                      Some explanation text.\n\
433                      --->  The brown fox jumps over the lazy dog.\n\
434                      More explanation.\n\
435                      --->  result = compute(some, arguments, here) + offset;\n";
436        let lines: Vec<&str> = edited.lines().collect();
437        assert!(s.check(&lines));
438    }
439
440    #[test]
441    fn advance_increments_current_and_resets_count() {
442        let mut s = TutorSession::load(2, TOTAL, lesson_text(), exercises_toml()).unwrap();
443        let lines: Vec<&str> = lesson_text().lines().collect();
444        s.check(&lines); // bumps attempt_count to 1
445        s.advance();
446        assert_eq!(s.current, 1);
447        assert_eq!(s.attempt_count, 0);
448    }
449
450    #[test]
451    fn retreat_returns_to_previous() {
452        let mut s = TutorSession::load(2, TOTAL, lesson_text(), exercises_toml()).unwrap();
453        s.advance();
454        assert_eq!(s.current, 1);
455        s.retreat();
456        assert_eq!(s.current, 0);
457    }
458
459    #[test]
460    fn retreat_noop_at_zero() {
461        let mut s = TutorSession::load(2, TOTAL, lesson_text(), exercises_toml()).unwrap();
462        s.retreat();
463        assert_eq!(s.current, 0);
464    }
465
466    #[test]
467    fn is_complete_after_all_advances() {
468        let mut s = TutorSession::load(2, TOTAL, lesson_text(), exercises_toml()).unwrap();
469        assert!(!s.is_complete());
470        s.advance();
471        assert!(!s.is_complete());
472        s.advance();
473        assert!(s.is_complete());
474        assert!(s.current_exercise().is_none());
475    }
476}