Skip to main content

rsnaker/game_logic/
high_score.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sled::Db;
4use std::path::Path;
5use tracing::{debug, info};
6
7const MAX_SCORE_ENTRIES: usize = 10;
8const DB_FILE: &str = "high_scores.db";
9#[derive(Serialize, Deserialize, Debug, Clone)]
10pub struct HighScore {
11    pub symbols: String,
12    pub score: u32,
13    pub speed: String,
14    // snake_growth_factor is a new field that we added to the HighScore struct,
15    // add a default value for backward compatibility
16    #[serde(default = "default_snake_growth_factor")]
17    pub snake_growth_factor: u16,
18    pub date: DateTime<Utc>,
19    pub version: String,
20}
21impl HighScore {
22    #[must_use]
23    pub fn new(symbols: String, score: u32, speed: String, snake_growth_factor: u16) -> Self {
24        HighScore {
25            symbols,
26            score,
27            speed,
28            snake_growth_factor,
29            date: Utc::now(),
30            version: env!("CARGO_PKG_VERSION").to_string(),
31        }
32    }
33}
34/// Add a default value (in function because of serde) to adapt for older entries in db without `snake_growth` without breaking
35const fn default_snake_growth_factor() -> u16 {
36    1
37}
38/// # Motivation for this DB
39/// To use something else than SQL DB to change ;) Top-edge DB
40/// For a more rock solid DB in Rust use redb (more typed (no manuel BE management), more stable, less innovant)
41/// # Strengths
42/// - Sled database guarantees that its iterators, including those returned by `db.iter()` and `db.range()`,
43///   will return elements in lexicographical order of their keys. (as raw byte slices)
44/// - This is a fundamental feature of sled because it is built upon a $\text{Bw-Tree}$ structure, which is a type of ordered, persistent tree.
45/// - The sorting is strictly lexicographical; you must ensure that multibyte numeric keys are stored in Big-Endian byte order.
46/// - Big-Endian (BE): Stores the Most Significant Byte (MSB) first. This is required for correct lexicographical sorting of numbers.
47/// # To explore the DB content by hands:
48/// - cargo install sledcli (or use EDMA)
49/// - hex or str to change the view
50/// - Pairs or keys
51/// # Why TOML serialisation
52/// - Provide a better visualization by hands
53/// - Not too much data, TOML overhead is unseen
54/// - Already used for argument serialisation, avoid a new dependency
55///   (and the bincode crate story is a lesson-teller)
56/// - NB: on other projects with others constrains postcard,rkyv,or borsh
57pub struct HighScoreManager {
58    db: Db,
59}
60
61impl HighScoreManager {
62    /// # Errors
63    ///
64    /// If DB reads / writes issues
65    pub fn new() -> Result<Self, sled::Error> {
66        let manager = HighScoreManager::new_with_custom_path(DB_FILE)?;
67        Ok(manager)
68    }
69    /// # Errors
70    ///
71    /// If DB reads / writes issues
72    pub fn new_with_custom_path<P: AsRef<Path>>(path: P) -> Result<Self, sled::Error> {
73        let db = sled::open(path)?;
74        Ok(Self { db })
75    }
76    /// Save the score in the DB and then shrink the DB to `MAX_SCORE_ENTRIES`
77    /// if the score is not in the best `MAX_SCORE_ENTRIES`, it will not be inserted
78    /// # Errors
79    ///
80    /// If DB reads / writes issues
81    pub fn save_score(
82        &self,
83        score: &HighScore,
84    ) -> Result<Option<usize>, Box<dyn std::error::Error>> {
85        let encoded = toml::to_string(&score)?;
86        let rank = self.get_rank(score.score)?;
87        //If the score to save is among the top ranks, we save
88        if rank.is_some() {
89            // 1. Calculate the score index key prefix (for sorting, so Big Endian)
90            let score_key = (u32::MAX - score.score).to_be_bytes();
91
92            // 2. Get a globally unique, monotonically increasing ID from sled (u64)
93            // This replaces the timestamp for uniqueness.
94            let unique_id = self.db.generate_id()?;
95            let unique_id_bytes = unique_id.to_be_bytes();
96
97            // 3. Combine them to form the final key
98            //NB: if not uniq will overwrite the previous value for this key as sled is like a hashMap<[u8],[u8]>
99            //And NOT a HashMap<[u8],Vec<[u8]>>
100            let mut final_key = score_key.to_vec();
101            final_key.extend_from_slice(&unique_id_bytes);
102
103            self.db.insert(final_key, encoded.as_bytes())?;
104            self.db.flush()?;
105
106            //Now Shrink DB
107            self.shrink_db()?;
108        }
109        Ok(rank)
110    }
111    /// Shrink the DB to `MAX_SCORE_ENTRIES` size
112    ///
113    /// # Errors
114    ///
115    /// If DB reads / writes issues
116    pub fn shrink_db(&self) -> Result<(), Box<dyn std::error::Error>> {
117        let mut iter = self.db.iter();
118
119        // The iterator returns Result <(IVec, IVec)>, so we need to chain unwrap/error checks
120        // to get the key of the element to start the deletion range at.
121        let key_to_start_deleting_from = iter
122            .nth(MAX_SCORE_ENTRIES)
123            .map_or(Ok(None), |res| res.map(|(k, _)| Some(k)))?;
124
125        if let Some(start_key) = key_to_start_deleting_from {
126            // We iterate from 'start_key' up to the unbounded end of the tree.
127            let keys_to_delete = self.db.range(start_key..);
128
129            // 2.3. Build the deletion batch.
130            let mut batch = sled::Batch::default();
131
132            for key_value_result in keys_to_delete {
133                let (key, _) = key_value_result?;
134                batch.remove(key);
135            }
136
137            // 2.4. Apply the batch.
138            self.db.apply_batch(batch)?;
139        }
140        self.db.flush()?;
141        // If .skip(keep_count).next() returned None, there are 10 or fewer entries, so do nothing.
142        Ok(())
143    }
144
145    #[must_use]
146    pub fn get_top_scores(&self) -> Vec<HighScore> {
147        let mut high_score_entries: Vec<HighScore> = Vec::new();
148        // We iterate on our sled DB, which is lexicography sorted, so we iterated by top score
149        // to bottom score :)
150        for item in self.db.iter() {
151            //For each item we gonna get utf8 value
152            let (_key, value) = item.expect("error in db while iterating to get score");
153            if let Ok(s_toml) = std::str::from_utf8(&value) {
154                //we get the toml back!
155                //Toml crate uses serde under the hood, so conversion to
156                // Highscore is possible thanks to Serde Deserialization macro applied on Highscore struct
157                // So we get a Highscore struct
158                if let Ok(high_score_entry) = toml::from_str(s_toml) {
159                    //We add it to our vec
160                    high_score_entries.push(high_score_entry);
161                    //get only the best score, limited to <limit> usually 10,
162                    // Normally not need because of shrinking but in case of
163                    if high_score_entries.len() >= MAX_SCORE_ENTRIES {
164                        break;
165                    }
166                }
167            }
168        }
169        high_score_entries
170    }
171    /// Get the rank of the score submitted among the `MAX_SCORE_ENTRIES`
172    /// Use the Sled lexicographic order to have it free
173    /// # Errors
174    ///
175    /// If DB reads / writes issues
176    pub fn get_rank(
177        &self,
178        player_score_value: u32,
179    ) -> Result<Option<usize>, Box<dyn std::error::Error>> {
180        let mut rank = 1;
181        let logic = |rank: &usize| {
182            //Check we save only up to MAX_SCORE_ENTRIES score
183            if *rank <= MAX_SCORE_ENTRIES {
184                info!(
185                    Ranked = rank,
186                    PlayerScore = player_score_value,
187                    "Player score is in the top 10 scores ! "
188                );
189                return Ok(Some(*rank));
190            }
191            info!(
192                PlayerScore = player_score_value,
193                "Player score is not in the top 10 scores ! "
194            );
195            Ok(None)
196        };
197        // We iterate on our sled DB, which is lexicography sorted, so we iterated by top score
198        // to bottom score :)
199        for item in self.db.iter() {
200            let (key, _value) = item?;
201            // Key 4 first bytes is the score saved as (u32::MAX - score)
202            let current_key_score_bytes: [u8; 4] = key[0..4].try_into()?;
203            let current_key_score = u32::MAX - u32::from_be_bytes(current_key_score_bytes);
204            debug!(current_key_score, "Current key score with rank:{}", rank);
205            // We found the ranking! (as we compare from top score to bottom)
206            if player_score_value >= current_key_score {
207                return logic(&rank);
208            }
209            rank += 1;
210        }
211        //In case we do not have all entry fulfilled, any score will be saved, even if superior to none
212        logic(&rank)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use tempfile::tempdir;
220
221    #[test]
222    fn test_high_score_save_and_load() {
223        let dir = tempdir().unwrap();
224        let db_path = dir.path().join("test_high_scores.db");
225        let manager = HighScoreManager::new_with_custom_path(db_path).unwrap();
226
227        let score = HighScore::new("🐍•".to_string(), 100, "Normal".into(), 2);
228
229        manager.save_score(&score).unwrap();
230
231        let top_scores = manager.get_top_scores();
232        assert_eq!(top_scores.len(), 1);
233        assert_eq!(top_scores[0].score, 100);
234        assert_eq!(top_scores[0].symbols, "🐍•");
235        assert_eq!(top_scores[0].snake_growth_factor, 2);
236    }
237
238    #[test]
239    fn test_old_high_score_defaults_growth_factor() {
240        let score = HighScore::new("🐍•".to_string(), 100, "Normal".into(), 2);
241        let legacy_toml = toml::to_string(&score)
242            .unwrap()
243            .lines()
244            .filter(|line| !line.starts_with("snake_growth_factor"))
245            .collect::<Vec<_>>()
246            .join("\n");
247
248        let loaded: HighScore = toml::from_str(&legacy_toml).unwrap();
249        assert_eq!(loaded.snake_growth_factor, 1);
250    }
251
252    #[test]
253    fn test_ranking() {
254        let dir = tempdir().unwrap();
255        let db_path = dir.path().join("test_rank.db");
256        let manager = HighScoreManager::new_with_custom_path(db_path).unwrap();
257
258        let scores = vec![200, 50, 100];
259        for s in scores {
260            manager
261                .save_score(&HighScore::new("S".to_string(), s, "Normal 🐢".into(), 1))
262                .unwrap();
263        }
264
265        assert_eq!(manager.get_rank(250).unwrap(), Some(1));
266        assert_eq!(manager.get_rank(200).unwrap(), Some(1));
267        assert_eq!(manager.get_rank(150).unwrap(), Some(2));
268        assert_eq!(manager.get_rank(100).unwrap(), Some(2));
269        assert_eq!(manager.get_rank(75).unwrap(), Some(3));
270        assert_eq!(manager.get_rank(50).unwrap(), Some(3));
271        assert_eq!(manager.get_rank(10).unwrap(), Some(4));
272    }
273}