348 lines
13 KiB
Rust
348 lines
13 KiB
Rust
// 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::<usize>() + SPECIAL.len()
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
|
pub struct Profile {
|
|
#[serde(default)]
|
|
pub totals: BTreeMap<String, f64>,
|
|
#[serde(default)]
|
|
pub unlocked: BTreeMap<String, u64>, // achievement id ("heroes.2", "flawless") -> unix time
|
|
#[serde(default)]
|
|
pub hall: Vec<crate::names::Fallen>, // 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<String> {
|
|
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<f32>,
|
|
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<String> {
|
|
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"));
|
|
}
|
|
}
|