1use serde::Deserialize;
8
9pub const MAX_LIVES: u8 = 3;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum TutorGameState {
14 Active,
16 GameOver,
18 AllComplete,
20}
21
22#[derive(Debug, Clone, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum SuccessCondition {
28 TextChanged,
30 TextContains(String),
32 TextNotContains(String),
34 TextEquals(String),
36 ManualAdvance,
39}
40
41impl SuccessCondition {
42 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#[derive(Debug, Clone, Deserialize)]
59pub struct TutorExercise {
60 pub id: String,
62 pub description: String,
64 pub anchor: String,
68 pub success: SuccessCondition,
70 pub hint: String,
72}
73
74#[derive(Debug, Deserialize)]
78struct ExerciseSidecar {
79 exercises: Vec<TutorExercise>,
80}
81
82#[derive(Debug, Clone)]
86pub struct TutorSession {
87 pub lesson: u32,
88 pub total_lessons: u32,
90 pub exercises: Vec<TutorExercise>,
91 pub current: usize,
93 pub attempt_count: usize,
96 pub anchor_lines: Vec<usize>,
99 pub initial_texts: Vec<String>,
102 pub last_version: u64,
104 pub lives: u8,
106 pub score: u32,
108 pub exercise_started_at: Option<std::time::Instant>,
110 pub state: TutorGameState,
112 pub high_score: u32,
115}
116
117impl TutorSession {
118 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 pub fn is_complete(&self) -> bool {
171 self.current >= self.exercises.len()
172 }
173
174 pub fn current_exercise(&self) -> Option<&TutorExercise> {
176 self.exercises.get(self.current)
177 }
178
179 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 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 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 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 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
272impl 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#[cfg(test)]
298mod tests {
299 use super::*;
300
301 const TOTAL: u32 = 7;
302
303 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); 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}