use crate::sim::TideEvent;
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
pub(super) fn save_path() -> std::path::PathBuf {
crate::app::paths::data_dir().join("achievements.txt")
}
#[derive(Resource, Default, Clone, PartialEq, Eq, Debug)]
pub struct Stats {
pub banked: u32,
pub golden: u32,
pub lures: u32,
pub events: u32,
pub gulls_fed: u32,
pub raids_taken: u32,
pub rounds: u32,
pub wins: u32,
pub puzzles: u32,
pub dry_wins: u32,
pub daily_day: u32,
pub daily_best: u32,
pub giants: u32,
pub events_seen: u8,
pub best_round: u32,
pub series_wins: u32,
pub online_wins: u32,
pub daily_days: u32,
pub levels_built: u32,
pub campaign_done: u32,
pub beach_done: u32,
pub raids_this_round: u32,
pub banked_this_round: u32,
}
#[derive(Resource, Default)]
pub struct Unlocked(pub HashSet<&'static str>);
pub struct Achievement {
pub id: &'static str,
stat: fn(&Stats) -> u32,
threshold: u32,
}
pub const ACHIEVEMENTS: [Achievement; 32] = [
Achievement {
id: "first_bank",
stat: |s| s.banked,
threshold: 1,
},
Achievement {
id: "bank_100",
stat: |s| s.banked,
threshold: 100,
},
Achievement {
id: "bank_1000",
stat: |s| s.banked,
threshold: 1000,
},
Achievement {
id: "golden_1",
stat: |s| s.golden,
threshold: 1,
},
Achievement {
id: "golden_10",
stat: |s| s.golden,
threshold: 10,
},
Achievement {
id: "lure_5",
stat: |s| s.lures,
threshold: 5,
},
Achievement {
id: "event_5",
stat: |s| s.events,
threshold: 5,
},
Achievement {
id: "win_1",
stat: |s| s.wins,
threshold: 1,
},
Achievement {
id: "win_10",
stat: |s| s.wins,
threshold: 10,
},
Achievement {
id: "puzzle_5",
stat: |s| s.puzzles,
threshold: 5,
},
Achievement {
id: "puzzle_25",
stat: |s| s.puzzles,
threshold: 25,
},
Achievement {
id: "puzzle_50",
stat: |s| s.puzzles,
threshold: 50,
},
Achievement {
id: "dry_castle",
stat: |s| s.dry_wins,
threshold: 1,
},
Achievement {
id: "giant_25",
stat: |s| s.giants,
threshold: 25,
},
Achievement {
id: "events_all",
stat: |s| s.events_seen.count_ones(),
threshold: TideEvent::ALL.len() as u32,
},
Achievement {
id: "haul_50",
stat: |s| s.best_round,
threshold: 50,
},
Achievement {
id: "series_1",
stat: |s| s.series_wins,
threshold: 1,
},
Achievement {
id: "online_1",
stat: |s| s.online_wins,
threshold: 1,
},
Achievement {
id: "daily_7",
stat: |s| s.daily_days,
threshold: 7,
},
Achievement {
id: "built_1",
stat: |s| s.levels_built,
threshold: 1,
},
Achievement {
id: "rounds_100",
stat: |s| s.rounds,
threshold: 100,
},
Achievement {
id: "bank_5000",
stat: |s| s.banked,
threshold: 5_000,
},
Achievement {
id: "golden_50",
stat: |s| s.golden,
threshold: 50,
},
Achievement {
id: "giant_100",
stat: |s| s.giants,
threshold: 100,
},
Achievement {
id: "haul_100",
stat: |s| s.best_round,
threshold: 100,
},
Achievement {
id: "win_50",
stat: |s| s.wins,
threshold: 50,
},
Achievement {
id: "daily_30",
stat: |s| s.daily_days,
threshold: 30,
},
Achievement {
id: "built_10",
stat: |s| s.levels_built,
threshold: 10,
},
Achievement {
id: "fed_25",
stat: |s| s.gulls_fed,
threshold: 25,
},
Achievement {
id: "campaign_done",
stat: |s| s.campaign_done,
threshold: 1,
},
Achievement {
id: "beach_done",
stat: |s| s.beach_done,
threshold: 1,
},
Achievement {
id: "series_5",
stat: |s| s.series_wins,
threshold: 5,
},
];
impl Achievement {
pub fn met(&self, stats: &Stats) -> bool {
(self.stat)(stats) >= self.threshold
}
pub fn progress(&self, stats: &Stats) -> (u32, u32) {
((self.stat)(stats).min(self.threshold), self.threshold)
}
}
mod save;
mod track;
mod ui;
pub use save::load;
#[cfg(test)]
use save::{parse, to_text};
pub use track::{
record_level_built, record_puzzle, record_round, reset_round_scratch, save_now, track_events,
};
pub use ui::{AchievementsUi, achievements_input, enter_achievements, update_toasts};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stats_round_trip_and_lenient_parse() {
let stats = Stats {
banked: 123,
golden: 4,
lures: 5,
events: 6,
gulls_fed: 7,
raids_taken: 8,
rounds: 9,
wins: 3,
puzzles: 11,
dry_wins: 1,
daily_day: 20_600,
daily_best: 42,
giants: 26,
events_seen: 0b1010_1010,
best_round: 51,
series_wins: 2,
online_wins: 4,
daily_days: 7,
levels_built: 3,
campaign_done: 1,
beach_done: 0,
raids_this_round: 99,
banked_this_round: 77,
};
let mut unlocked = Unlocked::default();
unlocked.0.insert("first_bank");
unlocked.0.insert("golden_1");
let (reparsed, re_unlocked) = parse(&to_text(&stats, &unlocked));
assert_eq!(reparsed.banked, 123);
assert_eq!(reparsed.wins, 3);
assert_eq!(reparsed.raids_this_round, 0, "scratch resets");
assert_eq!(reparsed.banked_this_round, 0, "scratch resets");
assert_eq!(reparsed.daily_day, 20_600);
assert_eq!(reparsed.daily_best, 42);
assert_eq!(
reparsed.campaign_done, 1,
"a finished campaign stays finished"
);
assert_eq!(reparsed.beach_done, 0);
assert_eq!(reparsed.giants, 26);
assert_eq!(reparsed.events_seen, 0b1010_1010, "the roulette bitmask");
assert_eq!(reparsed.best_round, 51);
assert_eq!(reparsed.series_wins, 2);
assert_eq!(reparsed.online_wins, 4);
assert_eq!(reparsed.daily_days, 7);
assert_eq!(reparsed.levels_built, 3);
assert!(re_unlocked.0.contains("first_bank"));
assert!(re_unlocked.0.contains("golden_1"));
let (s, u) = parse("banked: nope\nunlocked: bogus,win_1\nwibble: 3\n");
assert_eq!(s.banked, 0);
assert!(u.0.contains("win_1") && u.0.len() == 1);
}
#[test]
fn every_achievement_is_named_in_every_language() {
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
assert_eq!(
tr.ach_names.len(),
ACHIEVEMENTS.len(),
"{lang:?} names the wrong number of trophies"
);
for (index, achievement) in ACHIEVEMENTS.iter().enumerate() {
assert!(
!tr.ach_names[index].is_empty() && !tr.ach_descs[index].is_empty(),
"{lang:?} leaves {} unlabelled",
achievement.id
);
}
}
let mut ids: Vec<&str> = ACHIEVEMENTS.iter().map(|a| a.id).collect();
ids.sort_unstable();
let count = ids.len();
ids.dedup();
assert_eq!(ids.len(), count, "duplicate achievement id");
for achievement in &ACHIEVEMENTS {
assert!(achievement.threshold > 0, "{} is free", achievement.id);
assert!(
!achievement.met(&Stats::default()),
"{} unlocks on a fresh save",
achievement.id
);
}
}
#[test]
fn thresholds_gate_unlocks() {
let mut stats = Stats::default();
let bank_100 = &ACHIEVEMENTS[1];
assert!(!bank_100.met(&stats));
stats.banked = 99;
assert!(!bank_100.met(&stats));
assert_eq!(bank_100.progress(&stats), (99, 100));
stats.banked = 100;
assert!(bank_100.met(&stats));
let dry = ACHIEVEMENTS.iter().find(|a| a.id == "dry_castle").unwrap();
stats.dry_wins = 1;
assert!(dry.met(&stats));
}
}