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