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