Statistics with a chart at the end of a game, 64 achievements kept locally and on the server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 23:36:19 +01:00
1 parent 193b199875
commit 9931ca9b54
37 files changed
+1833 -4

No files matched your search

+310
View File
@@ -0,0 +1,310 @@
// 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, icon cell
pub const TIERS: [(&str, &[f64], u32); 20] = [
("heroes", &[1.0, 50.0, 250.0, 1000.0], crate::gfx::I_ARROW),
("bosses", &[1.0, 5.0, 20.0], crate::gfx::I_RUNE),
("dug", &[100.0, 1000.0, 10000.0], crate::gfx::I_PICK),
("gold", &[10000.0, 100000.0, 1000000.0], crate::gfx::I_SELL),
("wood", &[50.0, 500.0, 2000.0], crate::gfx::I_BRIDGE),
("walls", &[10.0, 100.0], crate::gfx::I_WALL),
("rooms", &[50.0, 500.0, 2000.0], crate::gfx::I_STEEL),
("spells", &[10.0, 100.0, 500.0], crate::gfx::I_BOLT),
("slaps", &[10.0, 100.0, 1000.0], crate::gfx::I_HAND),
("traps", &[5.0, 50.0, 200.0], crate::gfx::I_TRAPS),
("towers", &[5.0, 50.0], crate::gfx::I_CANNON),
("drowned", &[1.0, 25.0, 100.0], crate::gfx::I_FREEZE),
("burnt", &[5.0, 50.0], crate::gfx::I_DRAWBRIDGE),
("siege", &[1.0, 10.0], crate::gfx::I_CATAPULT),
("captured", &[1.0, 25.0, 100.0], crate::gfx::I_SUMMON),
("drawbridge", &[1.0, 50.0], crate::gfx::I_DRAWBRIDGE),
("wins", &[1.0, 10.0, 50.0], crate::gfx::I_FLAG),
("missions", &[1.0, 10.0, 30.0], crate::gfx::I_MAGIC),
("coop", &[1.0, 10.0, 20.0], crate::gfx::I_HEAL),
("minutes", &[60.0, 600.0, 3000.0], crate::gfx::I_SPELLS),
];
// special achievements: id, hidden until unlocked, icon cell
pub const SPECIAL: [(&str, bool, u32); 8] = [
("flawless", false, crate::gfx::I_HEAL),
("speedrun", false, crate::gfx::I_BOLT),
("campaign", false, crate::gfx::I_RUNE),
("surface", false, crate::gfx::I_WALL),
("defeat", true, crate::gfx::I_SELL),
("legend", false, crate::gfx::I_ARROW),
("wyrm", true, crate::gfx::I_SUMMON),
("fullhand", true, crate::gfx::I_HAND),
];
// 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
}
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);
}
}
// 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>,
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], 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.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() || 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 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, icon, 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 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"));
}
}