Skip to main content

lattice_host/tutor/
scores.rs

1//! T.B — Tutor high-score persistence.
2//!
3//! `TutorScores` is a thin wrapper around a per-lesson high-score map
4//! written to `~/.config/lattice/tutor-scores.toml` — the config home on
5//! Linux AND macOS (honouring `$XDG_CONFIG_HOME`), `%APPDATA%\lattice\`
6//! on Windows. Everything lattice keeps lives under one root; scores used
7//! to sit in `dirs::data_local_dir()`, outside it.
8//!
9//! The file is read at `do_tutor` time so the HUD can show the current
10//! high score, and written whenever a lesson is completed or the final
11//! lesson is beaten.  All I/O errors are logged at `debug!` and
12//! silently swallowed — the game continues without persistence rather
13//! than surfacing a confusing error to the user.
14
15use std::collections::HashMap;
16use std::path::PathBuf;
17
18use serde::{Deserialize, Serialize};
19
20// ---- Data model -------------------------------------------------------
21
22#[derive(Debug, Default, Clone, Serialize, Deserialize)]
23pub struct TutorScores {
24    /// lesson number (1-based) → all-time high score for that lesson.
25    #[serde(default)]
26    pub lessons: HashMap<u32, u32>,
27}
28
29impl TutorScores {
30    /// High score for `lesson`, or 0 if never completed.
31    pub fn high_score(&self, lesson: u32) -> u32 {
32        self.lessons.get(&lesson).copied().unwrap_or(0)
33    }
34
35    /// Update the high score for `lesson` if `score` beats the current
36    /// record.  Returns `true` when the record was broken.
37    pub fn record(&mut self, lesson: u32, score: u32) -> bool {
38        let entry = self.lessons.entry(lesson).or_insert(0);
39        if score > *entry {
40            *entry = score;
41            true
42        } else {
43            false
44        }
45    }
46
47    // ---- I/O -----------------------------------------------------------
48
49    /// Load from the XDG data dir, returning a default (empty) instance
50    /// on any I/O or parse error.
51    pub fn load_or_default() -> Self {
52        let Some(path) = scores_path() else {
53            return Self::default();
54        };
55        let Ok(bytes) = std::fs::read_to_string(&path) else {
56            return Self::default();
57        };
58        match toml::from_str::<Self>(&bytes) {
59            Ok(s) => s,
60            Err(e) => {
61                tracing::debug!(
62                    target: "lattice_host::tutor_scores",
63                    path = %path.display(),
64                    error = %e,
65                    "tutor-scores: parse error, starting fresh"
66                );
67                Self::default()
68            }
69        }
70    }
71
72    /// Persist to the XDG data dir.  Logs and swallows any I/O errors.
73    pub fn save(&self) {
74        let Some(path) = scores_path() else { return };
75        if let Some(parent) = path.parent()
76            && let Err(e) = std::fs::create_dir_all(parent)
77        {
78            tracing::debug!(
79                target: "lattice_host::tutor_scores",
80                path = %parent.display(),
81                error = %e,
82                "tutor-scores: could not create data dir"
83            );
84            return;
85        }
86        match toml::to_string_pretty(self) {
87            Ok(text) => {
88                if let Err(e) = std::fs::write(&path, text) {
89                    tracing::debug!(
90                        target: "lattice_host::tutor_scores",
91                        path = %path.display(),
92                        error = %e,
93                        "tutor-scores: write failed"
94                    );
95                }
96            }
97            Err(e) => {
98                tracing::debug!(
99                    target: "lattice_host::tutor_scores",
100                    error = %e,
101                    "tutor-scores: serialization failed"
102                );
103            }
104        }
105    }
106}
107
108fn scores_path() -> Option<PathBuf> {
109    let path = lattice_config::config_home().map(|d| d.join("lattice").join(SCORES_FILE))?;
110    if !path.exists()
111        && let Some(legacy) = dirs::data_local_dir().map(|d| d.join("lattice").join(SCORES_FILE))
112        && legacy.exists()
113    {
114        // Carry the scores from the pre-2026-09-22 location, once. A failure
115        // here is not worth a message: the game plays without persistence,
116        // and the next save writes the new path anyway.
117        if let Some(parent) = path.parent() {
118            let _ = std::fs::create_dir_all(parent);
119        }
120        match std::fs::rename(&legacy, &path) {
121            Ok(()) => tracing::debug!(
122                target: "lattice_host::tutor_scores",
123                to = %path.display(),
124                "tutor-scores: moved under the config home"
125            ),
126            Err(e) => tracing::debug!(
127                target: "lattice_host::tutor_scores",
128                error = %e,
129                "tutor-scores: could not move the old file"
130            ),
131        }
132    }
133    Some(path)
134}
135
136const SCORES_FILE: &str = "tutor-scores.toml";