use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sled::Db;
use std::path::Path;
const MAX_SCORE_ENTRIES: usize = 10;
const DB_FILE: &str = "high_scores.db";
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct HighScore {
pub symbols: String,
pub score: u32,
pub speed: String,
pub date: DateTime<Utc>,
pub version: String,
}
impl HighScore {
#[must_use]
pub fn new(symbols: String, score: u32, speed: String) -> Self {
HighScore {
symbols,
score,
speed,
date: Utc::now(),
version: env!("CARGO_PKG_VERSION").to_string(),
}
}
}
pub struct HighScoreManager {
db: Db,
}
impl HighScoreManager {
pub fn new() -> Result<Self, sled::Error> {
let manager = HighScoreManager::new_with_custom_path(DB_FILE)?;
Ok(manager)
}
pub fn new_with_custom_path<P: AsRef<Path>>(path: P) -> Result<Self, sled::Error> {
let db = sled::open(path)?;
Ok(Self { db })
}
pub fn save_score(&self, score: &HighScore) -> Result<(), Box<dyn std::error::Error>> {
let encoded = toml::to_string(&score)?;
if self.get_rank(score.score)?.is_some() {
let score_key = (u32::MAX - score.score).to_be_bytes();
let unique_id = self.db.generate_id()?;
let unique_id_bytes = unique_id.to_be_bytes();
let mut final_key = score_key.to_vec();
final_key.extend_from_slice(&unique_id_bytes);
self.db.insert(final_key, encoded.as_bytes())?;
self.db.flush()?;
self.shrink_db()?;
}
Ok(())
}
pub fn shrink_db(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut iter = self.db.iter();
let key_to_start_deleting_from = iter
.nth(MAX_SCORE_ENTRIES)
.map_or(Ok(None), |res| res.map(|(k, _)| Some(k)))?;
if let Some(start_key) = key_to_start_deleting_from {
let keys_to_delete = self.db.range(start_key..);
let mut batch = sled::Batch::default();
for key_value_result in keys_to_delete {
let (key, _) = key_value_result?;
batch.remove(key);
}
self.db.apply_batch(batch)?;
}
self.db.flush()?;
Ok(())
}
#[must_use]
pub fn get_top_scores(&self) -> Vec<HighScore> {
let mut high_score_entries: Vec<HighScore> = Vec::new();
for item in self.db.iter() {
let (_key, value) = item.expect("error in db while iterating to get score");
if let Ok(s_toml) = std::str::from_utf8(&value) {
if let Ok(high_score_entry) = toml::from_str(s_toml) {
high_score_entries.push(high_score_entry);
if high_score_entries.len() >= MAX_SCORE_ENTRIES {
break;
}
}
}
}
high_score_entries
}
pub fn get_rank(
&self,
player_score_value: u32,
) -> Result<Option<usize>, Box<dyn std::error::Error>> {
let mut rank = 1;
for item in self.db.iter() {
let (key, _value) = item?;
let current_key_score_bytes: [u8; 4] = key[0..4].try_into()?;
let current_key_score = u32::MAX - u32::from_be_bytes(current_key_score_bytes);
if player_score_value >= current_key_score {
if rank <= MAX_SCORE_ENTRIES {
return Ok(Some(rank));
}
return Ok(None);
}
rank += 1;
}
if rank <= MAX_SCORE_ENTRIES {
Ok(Some(rank))
} else {
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_high_score_save_and_load() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test_high_scores.db");
let manager = HighScoreManager::new_with_custom_path(db_path).unwrap();
let score = HighScore::new("🐍•".to_string(), 100, "Normal".into());
manager.save_score(&score).unwrap();
let top_scores = manager.get_top_scores();
assert_eq!(top_scores.len(), 1);
assert_eq!(top_scores[0].score, 100);
assert_eq!(top_scores[0].symbols, "🐍•");
}
#[test]
fn test_ranking() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test_rank.db");
let manager = HighScoreManager::new_with_custom_path(db_path).unwrap();
let scores = vec![200, 50, 100];
for s in scores {
manager
.save_score(&HighScore::new("S".to_string(), s, "Normal 🐢".into()))
.unwrap();
}
assert_eq!(manager.get_rank(250).unwrap(), Some(1));
assert_eq!(manager.get_rank(200).unwrap(), Some(1));
assert_eq!(manager.get_rank(150).unwrap(), Some(2));
assert_eq!(manager.get_rank(100).unwrap(), Some(2));
assert_eq!(manager.get_rank(75).unwrap(), Some(3));
assert_eq!(manager.get_rank(50).unwrap(), Some(3));
assert_eq!(manager.get_rank(10).unwrap(), Some(4));
}
}