Skip to main content

lattice_host/tutor/
engine.rs

1//! TM.1 (tutor-mode-ownership): the tutor's gameplay engine — lesson open/seed, advance/retreat, score/lives gamification, the per-keystroke success check, and the HUD update — relocated here from dispatch.rs so the tutor module owns it. These stay `impl Editor` methods: a host-native builtin keeps its &mut Editor seam (BC.final).
2
3use crate::action::EchoLevel;
4use crate::buffers::BufferId;
5use crate::dispatch::{DoEditOutcome, RendererSignal};
6use crate::editor::Editor;
7
8impl Editor {
9    /// `:tutor [N]` -- open the interactive Lattice tutor lesson
10    /// `N`. Embeds the lesson via `include_str!`; copies to a temp
11    /// path so the user can edit. Phase 5.8.AD.5: returns
12    /// `Vec<RendererSignal>` because `do_edit` may emit signals.
13    pub fn do_tutor(&mut self, lesson: Option<u32>) -> Vec<RendererSignal> {
14        let lesson_num = lesson.unwrap_or(1);
15        let (lesson_text, exercises_toml): (&'static str, &'static str) = match lesson_num {
16            1 => (
17                include_str!("../../../../docs/user/tutor/lesson-1.md"),
18                include_str!("../../../../docs/user/tutor/lesson-1.exercises.toml"),
19            ),
20            2 => (
21                include_str!("../../../../docs/user/tutor/lesson-2.md"),
22                include_str!("../../../../docs/user/tutor/lesson-2.exercises.toml"),
23            ),
24            3 => (
25                include_str!("../../../../docs/user/tutor/lesson-3.md"),
26                include_str!("../../../../docs/user/tutor/lesson-3.exercises.toml"),
27            ),
28            4 => (
29                include_str!("../../../../docs/user/tutor/lesson-4.md"),
30                include_str!("../../../../docs/user/tutor/lesson-4.exercises.toml"),
31            ),
32            5 => (
33                include_str!("../../../../docs/user/tutor/lesson-5.md"),
34                include_str!("../../../../docs/user/tutor/lesson-5.exercises.toml"),
35            ),
36            6 => (
37                include_str!("../../../../docs/user/tutor/lesson-6.md"),
38                include_str!("../../../../docs/user/tutor/lesson-6.exercises.toml"),
39            ),
40            7 => (
41                include_str!("../../../../docs/user/tutor/lesson-7.md"),
42                include_str!("../../../../docs/user/tutor/lesson-7.exercises.toml"),
43            ),
44            n => {
45                self.set_message(
46                    EchoLevel::Error,
47                    format!(
48                        "lesson {n} doesn't exist (lessons 1-7 available); \
49                         contributions welcome"
50                    ),
51                );
52                return Vec::new();
53            }
54        };
55        let mut path = std::env::temp_dir();
56        path.push(format!("lattice-tutor-lesson-{lesson_num}.md"));
57        if let Err(e) = std::fs::write(&path, lesson_text) {
58            self.set_message(
59                EchoLevel::Error,
60                format!("tutor: failed to write lesson file: {e}"),
61            );
62            return Vec::new();
63        }
64        let outcome = self.do_edit(Some(path), false);
65        let signals = match outcome {
66            DoEditOutcome::Opened(s) | DoEditOutcome::Activated(s) | DoEditOutcome::Reloaded(s) => {
67                s
68            }
69            DoEditOutcome::Directory(d) => self.do_open_oil(Some(d)),
70            DoEditOutcome::NoFileName | DoEditOutcome::Failed => return Vec::new(),
71        };
72        // T.4: seed TutorSession, TutorHeaderlineProvider, and tutor-mode
73        // on the buffer that was just opened (now self.document_buffer_id).
74        let buffer_id = self.document_buffer_id;
75        let mut session =
76            match crate::tutor::TutorSession::load(lesson_num, 7, lesson_text, exercises_toml) {
77                Ok(s) => s,
78                Err(e) => {
79                    self.set_message(
80                        EchoLevel::Error,
81                        format!("tutor: failed to load session: {e}"),
82                    );
83                    return signals;
84                }
85            };
86        // T.B: seed the HUD's HI: field from persisted scores.
87        session.high_score = crate::tutor::TutorScores::load_or_default().high_score(lesson_num);
88        // Provider — unregister any stale one (re-open path) then register fresh.
89        let provider_id = buffer_id.0 as u64 ^ crate::tutor::TUTOR_PROVIDER_TAG;
90        self.virtual_row_providers
91            .unregister(buffer_id, provider_id);
92        let handle = lattice_cells::SimpleHeaderlineHandle::new(
93            crate::tutor::TutorViewState::default(),
94            crate::tutor::render_tutor_headerline,
95        );
96        handle.update(|s| s.update_for_display(&session, crate::tutor::TutorHudKind::Normal));
97        self.virtual_row_providers
98            .register(buffer_id, std::sync::Arc::new(handle.provider(provider_id)));
99        // Store the state handle and session as buffer-locals.
100        self.buffer_locals
101            .entry(buffer_id)
102            .or_default()
103            .insert(crate::tutor::TutorHeaderlineState(handle));
104        self.buffer_locals
105            .entry(buffer_id)
106            .or_default()
107            .insert(session);
108        // Activate tutor-mode on this buffer.
109        let proto_id = lattice_protocol::ids::BufferId::new(buffer_id.0 as u64);
110        let mut active = self.active_modes.remove(&buffer_id).unwrap_or_default();
111        if let Err(e) = self.mode_registry.load_full().activate_minor(
112            &mut active,
113            &self.mode_guards,
114            &self.config,
115            &self.event_bus,
116            &self.services,
117            proto_id,
118            crate::tutor::TutorMode::mode_id(),
119            self.capabilities_for_proto(proto_id),
120        ) {
121            self.note_activation_failure(buffer_id, crate::tutor::TutorMode::mode_id(), &e);
122        }
123        self.active_modes.insert(buffer_id, active);
124        signals
125    }
126
127    /// `<CR>` / `<C-j>` in tutor-mode.
128    ///
129    /// - **GameOver**: skip past the current exercise (lose this one;
130    ///   advance without scoring).
131    /// - **Lesson complete**: open the next lesson, or show AllComplete
132    ///   if on the final lesson.
133    /// - **ManualAdvance exercise**: always advance (no penalty).
134    /// - **Condition met** (auto-detected by tick): advance.
135    /// - **Condition NOT met**: drain one life. If lives hit 0 the
136    ///   session transitions to `GameOver` and the headerline updates.
137    pub fn do_tutor_advance(&mut self) -> Vec<RendererSignal> {
138        use crate::tutor::{SuccessCondition, TutorGameState};
139
140        let buffer_id = self.document_buffer_id;
141        let Some(mut session) = self
142            .buffer_locals
143            .get(&buffer_id)
144            .and_then(|l| l.get::<crate::tutor::TutorSession>())
145            .cloned()
146        else {
147            return Vec::new();
148        };
149
150        // GameOver: <CR> skips — advance without scoring.
151        if session.state == TutorGameState::GameOver {
152            session.advance();
153            self.buffer_locals
154                .entry(buffer_id)
155                .or_default()
156                .insert(session.clone());
157            return self.tutor_after_advance(buffer_id, session);
158        }
159
160        // Lesson already complete: open the next lesson.
161        if session.is_complete() {
162            return self.tutor_open_next_or_complete(session);
163        }
164
165        let is_manual = matches!(
166            session.current_exercise().map(|e| &e.success),
167            Some(SuccessCondition::ManualAdvance)
168        );
169
170        if is_manual {
171            session.advance();
172            self.buffer_locals
173                .entry(buffer_id)
174                .or_default()
175                .insert(session.clone());
176            return self.tutor_after_advance(buffer_id, session);
177        }
178
179        // Peek: is the condition currently met?
180        let text = self.document.snapshot().buffer.as_string();
181        let owned: Vec<String> = text.lines().map(|l| l.to_owned()).collect();
182        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
183
184        if session.is_condition_met(&lines) {
185            session.advance();
186            self.buffer_locals
187                .entry(buffer_id)
188                .or_default()
189                .insert(session.clone());
190            return self.tutor_after_advance(buffer_id, session);
191        }
192
193        // Wrong answer: drain a life.
194        session.drain_life();
195        self.tutor_update_headerline(buffer_id, &session, crate::tutor::TutorHudKind::Normal);
196        self.buffer_locals
197            .entry(buffer_id)
198            .or_default()
199            .insert(session);
200        Vec::new()
201    }
202
203    /// `<C-k>` in tutor-mode.
204    ///
205    /// - **GameOver**: reload the current lesson from scratch (retry).
206    /// - **Active**: retreat to the previous exercise.
207    pub fn do_tutor_retreat(&mut self) -> Vec<RendererSignal> {
208        use crate::tutor::TutorGameState;
209
210        let buffer_id = self.document_buffer_id;
211        let Some(session) = self
212            .buffer_locals
213            .get(&buffer_id)
214            .and_then(|l| l.get::<crate::tutor::TutorSession>())
215            .cloned()
216        else {
217            return Vec::new();
218        };
219
220        if session.state == TutorGameState::GameOver {
221            // Full lesson reload: resets buffer text + spawns a fresh session.
222            return self.do_tutor(Some(session.lesson));
223        }
224
225        let mut session = session;
226        session.retreat();
227        self.tutor_update_headerline(buffer_id, &session, crate::tutor::TutorHudKind::Normal);
228        self.buffer_locals
229            .entry(buffer_id)
230            .or_default()
231            .insert(session);
232        Vec::new()
233    }
234
235    /// After a successful `session.advance()`, either open the next lesson
236    /// (lesson complete) or update the headerline for the new exercise.
237    fn tutor_after_advance(
238        &mut self,
239        buffer_id: BufferId,
240        session: crate::tutor::TutorSession,
241    ) -> Vec<RendererSignal> {
242        if session.is_complete() {
243            return self.tutor_open_next_or_complete(session);
244        }
245        self.tutor_update_headerline(buffer_id, &session, crate::tutor::TutorHudKind::Normal);
246        self.buffer_locals
247            .entry(buffer_id)
248            .or_default()
249            .insert(session);
250        Vec::new()
251    }
252
253    /// Open the next lesson, or mark AllComplete on the final lesson.
254    /// Saves the lesson's score to disk on every completion.
255    fn tutor_open_next_or_complete(
256        &mut self,
257        mut session: crate::tutor::TutorSession,
258    ) -> Vec<RendererSignal> {
259        use crate::tutor::TutorGameState;
260        // T.B: persist high score whenever a lesson is cleared.
261        let mut scores = crate::tutor::TutorScores::load_or_default();
262        let new_record = scores.record(session.lesson, session.score);
263        scores.save();
264        if new_record {
265            session.high_score = session.score;
266        }
267        let next = session.lesson + 1;
268        if next <= session.total_lessons {
269            return self.do_tutor(Some(next));
270        }
271        // Final lesson complete.
272        let buffer_id = self.document_buffer_id;
273        session.state = TutorGameState::AllComplete;
274        self.tutor_update_headerline(buffer_id, &session, crate::tutor::TutorHudKind::Normal);
275        self.buffer_locals
276            .entry(buffer_id)
277            .or_default()
278            .insert(session);
279        Vec::new()
280    }
281
282    /// Ask the tutor headerline provider for `buffer_id` to recompute
283    /// its HUD from `session`. All formatting stays in `TutorViewState`.
284    fn tutor_update_headerline(
285        &self,
286        buffer_id: BufferId,
287        session: &crate::tutor::TutorSession,
288        kind: crate::tutor::TutorHudKind,
289    ) {
290        if let Some(state) = self
291            .buffer_locals
292            .get(&buffer_id)
293            .and_then(|l| l.get::<crate::tutor::TutorHeaderlineState>())
294        {
295            state.0.update(|s| s.update_for_display(session, kind));
296        }
297    }
298
299    /// Auto-detect success per tick. Awards score when the condition
300    /// is first met; updates the headerline to a "STAGE CLEAR" prompt.
301    /// Does NOT drain lives — that only happens on explicit `<CR>` via
302    /// `do_tutor_advance`. Called from `publish_render_state`.
303    pub(crate) fn check_tutor_session(&mut self) {
304        let buffer_id = self.document_buffer_id;
305        let Some(mut session) = self
306            .buffer_locals
307            .get(&buffer_id)
308            .and_then(|l| l.get::<crate::tutor::TutorSession>())
309            .cloned()
310        else {
311            return;
312        };
313        use crate::tutor::TutorGameState;
314        if session.state == TutorGameState::GameOver || session.is_complete() {
315            return;
316        }
317        let current_version = self.document.version();
318        if current_version == session.last_version {
319            return;
320        }
321        session.last_version = current_version;
322        let text = self.document.snapshot().buffer.as_string();
323        let owned: Vec<String> = text.lines().map(|l| l.to_owned()).collect();
324        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
325        let success = session.check(&lines);
326        let kind = if success {
327            crate::tutor::TutorHudKind::StageClear
328        } else {
329            crate::tutor::TutorHudKind::Normal
330        };
331        self.tutor_update_headerline(buffer_id, &session, kind);
332        self.buffer_locals
333            .entry(buffer_id)
334            .or_default()
335            .insert(session);
336    }
337}