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, Copy, PartialEq, Eq, Debug)]
pub struct Unsaved(pub bool);
#[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: u16,
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 hosted: u32,
pub crowd: u32,
pub seats_won: u8,
pub under_par: u32,
pub clean_solves: u32,
pub deep_solves: u32,
pub daily_record: u32,
pub codes_shared: u32,
pub codes_taken: u32,
}
#[derive(Resource, Default, Debug)]
pub struct RoundScratch {
pub raids: u32,
pub banked: u32,
}
#[derive(Resource, Default)]
pub struct PuzzleAttempt {
stage: String,
pub retries: u32,
pub unbeaten: bool,
pub layout: Vec<crate::sim::Placement>,
}
impl PuzzleAttempt {
pub fn layout_of(&self, stage: &str) -> Option<&[crate::sim::Placement]> {
(self.stage == stage && !self.layout.is_empty()).then_some(self.layout.as_slice())
}
}
#[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; 50] = [
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: 5,
},
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: 25,
},
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,
},
Achievement {
id: "raids_25",
stat: |s| s.raids_taken,
threshold: 25,
},
Achievement {
id: "lure_50",
stat: |s| s.lures,
threshold: 50,
},
Achievement {
id: "event_50",
stat: |s| s.events,
threshold: 50,
},
Achievement {
id: "rounds_500",
stat: |s| s.rounds,
threshold: 500,
},
Achievement {
id: "puzzle_100",
stat: |s| s.puzzles,
threshold: 100,
},
Achievement {
id: "built_25",
stat: |s| s.levels_built,
threshold: 25,
},
Achievement {
id: "online_10",
stat: |s| s.online_wins,
threshold: 10,
},
Achievement {
id: "hosted_1",
stat: |s| s.hosted,
threshold: 1,
},
Achievement {
id: "crowd_4",
stat: |s| s.crowd,
threshold: 4,
},
Achievement {
id: "all_seats",
stat: |s| s.seats_won.count_ones(),
threshold: 4,
},
Achievement {
id: "under_par_1",
stat: |s| s.under_par,
threshold: 1,
},
Achievement {
id: "clean_10",
stat: |s| s.clean_solves,
threshold: 10,
},
Achievement {
id: "deep_1",
stat: |s| s.deep_solves,
threshold: 1,
},
Achievement {
id: "daily_40",
stat: |s| s.daily_record,
threshold: 40,
},
Achievement {
id: "shared_1",
stat: |s| s.codes_shared,
threshold: 1,
},
Achievement {
id: "taken_1",
stat: |s| s.codes_taken,
threshold: 1,
},
Achievement {
id: "crowd_6",
stat: |s| s.crowd,
threshold: crate::sim::MAX_PLAYERS as u32,
},
Achievement {
id: "under_par_10",
stat: |s| s.under_par,
threshold: 10,
},
];
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::{
note_posts_spent, record_codes, record_level_built, record_puzzle, record_round,
reset_puzzle_attempt, reset_round_scratch, save_now, save_on_exit, track_events,
track_puzzle_attempt,
};
pub use ui::{
AchievementsUi, achievements_input, enter_achievements, update_shelf_scrollbar, 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,
hosted: 2,
crowd: 4,
seats_won: 0b0000_1011,
under_par: 3,
clean_solves: 12,
deep_solves: 1,
daily_record: 41,
codes_shared: 5,
codes_taken: 6,
};
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.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_eq!(reparsed.hosted, 2);
assert_eq!(reparsed.crowd, 4);
assert_eq!(reparsed.seats_won, 0b0000_1011, "the seats-sat-at bitmask");
assert_eq!(reparsed.under_par, 3);
assert_eq!(reparsed.clean_solves, 12);
assert_eq!(reparsed.deep_solves, 1);
assert_eq!(reparsed.daily_record, 42, "today's best counts as record");
let (old_save, _) = parse("daily_best: 55\n");
assert_eq!(
old_save.daily_record, 55,
"a save from before the record existed"
);
assert_eq!(reparsed.codes_shared, 5);
assert_eq!(reparsed.codes_taken, 6);
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 every_trophy_description_fits_what_is_left_of_its_row() {
use crate::app::i18n::metrics::text_px;
use super::ui::{NAME_PX, ROW_PX};
const PADDING_PX: f32 = 8.0 * 2.0;
const MARK_PX: f32 = 14.0;
const GAPS_PX: f32 = 8.0 * 3.0;
let fine = crate::app::menu_ui::type_scale::FINE;
let mut over = Vec::new();
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
for (index, achievement) in ACHIEVEMENTS.iter().enumerate() {
let (_, goal) = achievement.progress(&Stats::default());
let count = text_px(&format!("{goal}/{goal}"), fine);
let room = ROW_PX - PADDING_PX - MARK_PX - NAME_PX - GAPS_PX - count;
let desc = tr.ach_descs[index];
let width = text_px(desc, fine);
if width > room {
over.push(format!(
"{lang:?} {} {desc:?} {width:.0}px into {room:.0}px",
achievement.id
));
}
}
}
assert!(over.is_empty(), "clipped:\n{}", over.join("\n"));
}
#[test]
fn no_two_trophies_share_a_name() {
let mut clashes = Vec::new();
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
for (index, achievement) in ACHIEVEMENTS.iter().enumerate() {
let name = tr.ach_names[index];
if let Some(first) = seen.insert(name, achievement.id) {
clashes.push(format!("{lang:?} {name:?}: {first} and {}", achievement.id));
}
}
}
assert!(clashes.is_empty(), "shared names:\n{}", clashes.join("\n"));
}
#[test]
fn every_trophy_name_fits_its_column() {
const NAME_COLUMN_PX: f32 = ui::NAME_PX;
let size = crate::app::menu_ui::type_scale::BODY;
let mut over = Vec::new();
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
for (index, achievement) in ACHIEVEMENTS.iter().enumerate() {
let name = tr.ach_names[index];
let width = crate::app::i18n::metrics::text_px(name, size);
if width > NAME_COLUMN_PX {
over.push(format!("{lang:?} {} {name:?} {width:.0}px", achievement.id));
}
}
}
assert!(
over.is_empty(),
"past a {NAME_COLUMN_PX:.0}px column:\n{}",
over.join("\n")
);
}
#[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));
}
}