// Achievements: a lifetime profile of the player (totals of every game's statistics, wins, // minutes played) and 64 achievements - tiers of counters (bronze, silver, gold, ...) and a few // special, partly hidden ones. Kept in the browser; after a login merged with the server's copy // (the larger counter wins, unlocked stays unlocked) and stored there too. use crate::game::*; use crate::stats as k; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; pub const PROFILE_KEY: &str = "dc.profile"; // counters of the profile: the game statistics (stats.rs) and a few of its own pub const STAT_NAMES: [&str; k::COUNTERS] = [ "heroes", "bosses", "lost", "dug", "wood", "walls", "rooms", "spells", "slaps", "traps", "towers", "drowned", "burnt", "siege", "captured", "gold", "drawbridge", ]; // tiered achievements: id (= counter), thresholds, badge (badges.rs) pub const TIERS: [(&str, &[f64], u32); 20] = [ ("heroes", &[1.0, 50.0, 250.0, 1000.0], crate::badges::HEROES), ("bosses", &[1.0, 5.0, 20.0], crate::badges::BOSSES), ("dug", &[100.0, 1000.0, 10000.0], crate::badges::DUG), ("gold", &[10000.0, 100000.0, 1000000.0], crate::badges::GOLD), ("wood", &[50.0, 500.0, 2000.0], crate::badges::WOOD), ("walls", &[10.0, 100.0], crate::badges::WALLS), ("rooms", &[50.0, 500.0, 2000.0], crate::badges::ROOMS), ("spells", &[10.0, 100.0, 500.0], crate::badges::SPELLS), ("slaps", &[10.0, 100.0, 1000.0], crate::badges::SLAPS), ("traps", &[5.0, 50.0, 200.0], crate::badges::TRAPS), ("towers", &[5.0, 50.0], crate::badges::TOWERS), ("drowned", &[1.0, 25.0, 100.0], crate::badges::DROWNED), ("burnt", &[5.0, 50.0], crate::badges::BURNT), ("siege", &[1.0, 10.0], crate::badges::SIEGE), ("captured", &[1.0, 25.0, 100.0], crate::badges::CAPTURED), ("drawbridge", &[1.0, 50.0], crate::badges::DRAWBRIDGE), ("wins", &[1.0, 10.0, 50.0], crate::badges::WINS), ("missions", &[1.0, 10.0, 30.0], crate::badges::MISSIONS), ("coop", &[1.0, 10.0, 20.0], crate::badges::COOP), ("minutes", &[60.0, 600.0, 3000.0], crate::badges::MINUTES), ]; // special achievements: id, hidden until unlocked, badge (badges.rs) pub const SPECIAL: [(&str, bool, u32); 8] = [ ("flawless", false, crate::badges::FLAWLESS), ("speedrun", false, crate::badges::SPEEDRUN), ("campaign", false, crate::badges::CAMPAIGN), ("surface", false, crate::badges::SURFACE), ("defeat", true, crate::badges::DEFEAT), ("legend", false, crate::badges::LEGEND), ("wyrm", true, crate::badges::WYRM), ("fullhand", true, crate::badges::FULLHAND), ]; // number of achievements in all #[cfg(test)] pub fn count() -> usize { TIERS.iter().map(|t| t.1.len()).sum::() + SPECIAL.len() } #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct Profile { #[serde(default)] pub totals: BTreeMap, #[serde(default)] pub unlocked: BTreeMap, // achievement id ("heroes.2", "flawless") -> unix time #[serde(default)] pub hall: Vec, // the hall of the fallen over all games (newest last) } // the hall of the fallen keeps this many pub const HALL_MAX: usize = 200; impl Profile { pub fn total(&self, id: &str) -> f64 { self.totals.get(id).copied().unwrap_or(0.0) } // the larger counter wins, unlocked stays unlocked (with the earlier date) pub fn merge(&mut self, other: &Profile) { for (k, v) in &other.totals { let e = self.totals.entry(k.clone()).or_insert(0.0); *e = e.max(*v); } for (k, t) in &other.unlocked { let e = self.unlocked.entry(k.clone()).or_insert(*t); *e = (*e).min(*t); } // the fallen of both, once each, in the order they fell for f in &other.hall { if !self.hall.iter().any(|h| h.name == f.name && h.kind == f.kind && h.when == f.when) { self.hall.push(f.clone()); } } self.hall.sort_by_key(|h| h.when); let extra = self.hall.len().saturating_sub(HALL_MAX); self.hall.drain(..extra); } // unlocks what the totals have reached; returns the new ones pub fn check_tiers(&mut self, now: u64) -> Vec { let mut new = Vec::new(); for (id, steps, _) in TIERS.iter() { let v = self.total(id); for (t, &need) in steps.iter().enumerate() { let key = format!("{}.{}", id, t + 1); if v >= need && !self.unlocked.contains_key(&key) { self.unlocked.insert(key.clone(), now); new.push(key); } } } new } pub fn unlock(&mut self, id: &str, now: u64) -> bool { if self.unlocked.contains_key(id) { return false; } self.unlocked.insert(id.to_string(), now); true } } // what the tracker needs to know about the running game besides the game itself pub struct Context { pub coop: bool, pub editor_test: bool, // test play from the editor counts nothing } // Follows the running game: adds what happened to the profile every few seconds and unlocks pub struct Tracker { pub profile: Profile, flushed: Vec, hall_seen: usize, // entries of the game's hall already in the profile last_time: f32, ended: bool, t: f32, pub dirty: bool, // changed since the last store pub upload: bool, // something new for the server } impl Tracker { pub fn new(profile: Profile) -> Tracker { Tracker { profile, flushed: vec![0.0; k::COUNTERS], hall_seen: 0, last_time: 0.0, ended: false, t: 0.0, dirty: false, upload: false } } // a different game started (new, loaded): nothing of it has been counted yet fn restart(&mut self, g: &Game) { self.flushed = (0..k::COUNTERS).map(|i| g.stats.get(i)).collect(); self.hall_seen = g.hall.len(); self.ended = g.state != 0; } // called every frame; returns achievements unlocked just now pub fn update(&mut self, g: &Game, ctx: &Context, dt: f32, now: u64) -> Vec { if g.demo.is_some() || g.demo_map.is_some() || ctx.editor_test { self.last_time = 0.0; return Vec::new(); } // a loaded or new game: its old counters are not counted again if g.time + 0.5 < self.last_time || self.last_time == 0.0 { self.restart(g); } self.last_time = g.time.max(0.01); self.t += dt; let ended_now = g.state != 0 && !self.ended; if self.t < 5.0 && !ended_now { return Vec::new(); } let played = self.t; self.t = 0.0; let mut new = Vec::new(); // the newly fallen go into the profile's hall if g.hall.len() > self.hall_seen { for f in &g.hall[self.hall_seen..] { self.profile.hall.push(crate::names::Fallen { when: now, ..f.clone() }); } self.hall_seen = g.hall.len(); let extra = self.profile.hall.len().saturating_sub(HALL_MAX); self.profile.hall.drain(..extra); self.dirty = true; self.upload = true; } // the counters of the game for i in 0..k::COUNTERS { let cur = g.stats.get(i); let d = cur - self.flushed[i]; if d > 0.0 { *self.profile.totals.entry(STAT_NAMES[i].to_string()).or_insert(0.0) += d as f64; self.flushed[i] = cur; self.dirty = true; } } if g.state == 0 { *self.profile.totals.entry("minutes".into()).or_insert(0.0) += (played / 60.0) as f64; self.dirty = true; } // the special ones that can happen any time if g.ents.iter().any(|e| e.alive && e.creature() && e.level >= 10) && self.profile.unlock("legend", now) { new.push("legend".to_string()); } if g.ents.iter().any(|e| e.alive && e.creature() && e.kind == EK::Wyrm) && self.profile.unlock("wyrm", now) { new.push("wyrm".to_string()); } if g.hand.len() >= 8 && self.profile.unlock("fullhand", now) { new.push("fullhand".to_string()); } // the end of the game if ended_now { self.ended = true; if g.state == 1 { let key = if ctx.coop { "coop" } else if g.mission.is_some() { "missions" } else { "wins" }; *self.profile.totals.entry(key.into()).or_insert(0.0) += 1.0; if g.stats.get(k::LOST) == 0.0 && self.profile.unlock("flawless", now) { new.push("flawless".into()); } if g.time < 15.0 * 60.0 && self.profile.unlock("speedrun", now) { new.push("speedrun".into()); } let last = crate::campaign::missions().len().saturating_sub(1); if !ctx.coop && g.mission.as_ref().map_or(false, |m| m.index == last) && self.profile.unlock("campaign", now) { new.push("campaign".into()); } if !g.map.sky.is_empty() && self.profile.unlock("surface", now) { new.push("surface".into()); } } else if self.profile.unlock("defeat", now) { new.push("defeat".into()); } self.dirty = true; } new.extend(self.profile.check_tiers(now)); if !new.is_empty() { self.dirty = true; self.upload = true; } new } } // the name and description of an achievement ("heroes.2" or "flawless") in the current language pub fn texts(id: &str) -> (String, String) { use crate::i18n::{tr, trf}; match id.split_once('.') { Some((cat, tier)) => { let t: usize = tier.parse().unwrap_or(1); let need = TIERS.iter().find(|x| x.0 == cat).and_then(|x| x.1.get(t - 1)).copied().unwrap_or(0.0); let roman = ["I", "II", "III", "IV", "V"][(t - 1).min(4)]; (trf(&format!("ach.{}.name", cat), &[roman.to_string()]), trf(&format!("ach.{}.desc", cat), &[fmt_count(need)])) } None => (tr(&format!("ach.{}.name", id)), tr(&format!("ach.{}.desc", id))), } } pub fn fmt_count(v: f64) -> String { let n = v.round() as i64; let s = n.to_string(); let mut out = String::new(); for (i, c) in s.chars().enumerate() { if i > 0 && (s.len() - i) % 3 == 0 { out.push('.'); } out.push(c); } out } // every achievement in display order: (id, hidden, badge, tier 0..) pub fn all() -> Vec<(String, bool, u32, usize)> { let mut v = Vec::new(); for (id, steps, icon) in TIERS.iter() { for t in 0..steps.len() { v.push((format!("{}.{}", id, t + 1), false, *icon, t)); } } for (id, hidden, icon) in SPECIAL.iter() { v.push((id.to_string(), *hidden, *icon, 9)); } v } #[cfg(test)] mod tests { use super::*; #[test] fn more_than_fifty_achievements() { assert!(count() >= 50, "{}", count()); assert_eq!(all().len(), count()); } #[test] fn every_achievement_has_its_own_badge() { let mut seen = std::collections::BTreeSet::new(); for b in TIERS.iter().map(|t| t.2).chain(SPECIAL.iter().map(|s| s.2)) { assert!(b < crate::badges::COUNT, "badge {} out of range", b); assert!(seen.insert(b), "badge {} used twice", b); } assert_eq!(seen.len() as u32, crate::badges::COUNT); } #[test] fn the_tracker_counts_a_game_and_unlocks() { let mut g = Game::new(3, 64, 64); let mut t = Tracker::new(Profile::default()); let ctx = Context { coop: false, editor_test: false }; g.time = 1.0; t.update(&g, &ctx, 0.1, 1); g.count(k::HEROES, 3.0); g.count(k::DUG, 150.0); g.time = 10.0; let new = t.update(&g, &ctx, 6.0, 2); assert!(new.contains(&"heroes.1".to_string()) && new.contains(&"dug.1".to_string()), "{:?}", new); assert_eq!(t.profile.total("heroes"), 3.0); // the same counters are not counted twice g.time = 20.0; t.update(&g, &ctx, 6.0, 3); assert_eq!(t.profile.total("heroes"), 3.0); // a won free game without losses g.state = 1; let new = t.update(&g, &ctx, 0.1, 4); assert!(new.contains(&"wins.1".to_string()) && new.contains(&"flawless".to_string()), "{:?}", new); } #[test] fn profiles_merge() { let mut a = Profile::default(); a.totals.insert("heroes".into(), 10.0); a.unlocked.insert("heroes.1".into(), 5); let mut b = Profile::default(); b.totals.insert("heroes".into(), 7.0); b.totals.insert("dug".into(), 100.0); b.unlocked.insert("heroes.1".into(), 3); b.unlocked.insert("dug.1".into(), 4); a.merge(&b); assert_eq!(a.total("heroes"), 10.0); assert_eq!(a.total("dug"), 100.0); assert_eq!(a.unlocked["heroes.1"], 3); assert!(a.unlocked.contains_key("dug.1")); } }