use crate::app::{Campaign, CampaignKind};
use bevy::prelude::*;
use std::collections::{HashMap, HashSet};
fn save_path() -> std::path::PathBuf {
crate::app::paths::data_dir().join("progress.txt")
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Ladder<'w> {
pub campaign: ResMut<'w, Campaign>,
pub progress: Res<'w, Progress>,
}
impl Ladder<'_> {
pub fn climb_to(&mut self, at: usize) -> bool {
let open = self.progress.unlocked(&self.campaign, at);
if open {
self.campaign.index = at;
}
open
}
}
#[derive(Resource, Default)]
pub struct Progress {
cleared: HashMap<CampaignKind, HashSet<String>>,
}
impl Progress {
pub fn is_cleared(&self, kind: CampaignKind, name: &str) -> bool {
self.cleared
.get(&kind)
.is_some_and(|names| names.contains(name))
}
pub fn mark(&mut self, kind: CampaignKind, name: &str) -> bool {
self.cleared
.entry(kind)
.or_default()
.insert(name.to_string())
}
pub fn cleared_count(&self) -> usize {
self.cleared.values().map(HashSet::len).sum()
}
pub fn clear_all(&mut self) {
self.cleared.clear();
}
pub fn unlocked(&self, campaign: &Campaign, index: usize) -> bool {
if index == 0 || index >= campaign.builtins {
return true;
}
let cleared = |at: usize| {
campaign
.levels
.get(at)
.is_some_and(|level| self.is_cleared(campaign.kind, &level.name))
};
cleared(index) || cleared(index - 1)
}
pub fn cleared_in(&self, campaign: &Campaign, range: std::ops::Range<usize>) -> usize {
campaign
.levels
.get(range)
.unwrap_or_default()
.iter()
.filter(|level| self.is_cleared(campaign.kind, &level.name))
.count()
}
pub fn furthest_open(&self, campaign: &Campaign) -> usize {
(0..campaign.levels.len())
.filter(|&index| self.unlocked(campaign, index))
.take_while(|&index| index < campaign.builtins)
.last()
.unwrap_or(0)
}
pub fn to_text(&self) -> String {
let Self { cleared } = self;
let mut keys: Vec<String> = cleared
.iter()
.flat_map(|(kind, names)| {
names
.iter()
.map(move |name| format!("{}:{name}", kind.key()))
})
.collect();
keys.sort_unstable();
keys.iter().fold(String::new(), |mut out, key| {
out.push_str("cleared: ");
out.push_str(key);
out.push('\n');
out
})
}
pub fn parse(text: &str) -> Progress {
let mut progress = Progress::default();
for line in text.lines() {
if let Some((key, value)) = line.split_once(':')
&& key.trim() == "cleared"
&& let Some((kind, name)) = value.trim().split_once(':')
&& let Some(kind) = CampaignKind::from_key(kind)
&& !name.is_empty()
{
progress.mark(kind, name);
}
}
progress
}
}
pub fn load(mut commands: Commands) {
commands.insert_resource(load_in(&save_path()));
}
pub fn load_in(path: &std::path::Path) -> Progress {
std::fs::read_to_string(path)
.map(|text| Progress::parse(&text))
.unwrap_or_default()
}
pub fn save(progress: &Progress) {
let _ = save_in(&save_path(), progress);
}
pub fn save_in(path: &std::path::Path, progress: &Progress) -> std::io::Result<()> {
crate::app::paths::write_atomic(path, progress.to_text())
}
pub fn record_cleared(campaign: Res<Campaign>, mut progress: ResMut<Progress>) {
let name = campaign.current().name.clone();
if progress.mark(campaign.kind, &name) {
save(&progress);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::{Level, campaign_levels};
fn campaign(builtins: usize, extra: Vec<Level>) -> Campaign {
let mut levels = campaign_levels();
levels.truncate(builtins);
let builtins = levels.len();
levels.extend(extra);
Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
}
}
#[test]
fn clearing_a_stage_opens_the_next_one_only() {
let campaign = campaign(5, vec![]);
let mut progress = Progress::default();
assert!(progress.unlocked(&campaign, 0));
for index in 1..5 {
assert!(!progress.unlocked(&campaign, index), "stage {index}");
}
let first = campaign.levels[0].name.clone();
assert!(progress.mark(CampaignKind::TidePool, &first));
assert!(!progress.mark(CampaignKind::TidePool, &first), "idempotent");
assert!(progress.unlocked(&campaign, 1));
assert!(!progress.unlocked(&campaign, 2), "no further than that");
assert_eq!(progress.cleared_in(&campaign, 0..campaign.levels.len()), 1);
assert_eq!(progress.furthest_open(&campaign), 1);
}
#[test]
fn the_two_campaigns_do_not_share_progress() {
let mut progress = Progress::default();
progress.mark(CampaignKind::TidePool, "Gull Storm");
assert!(progress.is_cleared(CampaignKind::TidePool, "Gull Storm"));
assert!(!progress.is_cleared(CampaignKind::BeachDay, "Gull Storm"));
}
#[test]
fn player_made_levels_are_always_open() {
let mine =
Level::parse(include_str!("../../levels/01_welcome_ashore.txt")).expect("parses");
let campaign = campaign(5, vec![mine]);
let progress = Progress::default();
assert!(
!progress.unlocked(&campaign, 4),
"still behind the campaign"
);
assert!(progress.unlocked(&campaign, 5), "the player's own level");
}
#[test]
fn a_list_this_build_lacks_is_ignored() {
let mixed = Progress::parse("cleared: reef:Far Shoal\ncleared: tide:Welcome Ashore\n");
assert!(mixed.is_cleared(CampaignKind::TidePool, "Welcome Ashore"));
assert_eq!(mixed.cleared_count(), 1);
assert_eq!(CampaignKind::from_key("reef"), None);
assert_eq!(
CampaignKind::from_key("beach"),
Some(CampaignKind::BeachDay)
);
}
#[test]
fn the_record_is_written_in_one_order() {
let mut progress = Progress::default();
progress.mark(CampaignKind::TidePool, "Undertow");
progress.mark(CampaignKind::TidePool, "Corner Pocket");
progress.mark(CampaignKind::BeachDay, "First Flood");
assert_eq!(
progress.to_text(),
"cleared: beach:First Flood\ncleared: tide:Corner Pocket\ncleared: tide:Undertow\n"
);
let mut reversed = Progress::default();
reversed.mark(CampaignKind::BeachDay, "First Flood");
reversed.mark(CampaignKind::TidePool, "Corner Pocket");
reversed.mark(CampaignKind::TidePool, "Undertow");
assert_eq!(reversed.to_text(), progress.to_text());
}
#[test]
fn progress_survives_a_save_and_load() {
let mut progress = Progress::default();
progress.mark(CampaignKind::TidePool, "Welcome Ashore");
progress.mark(CampaignKind::BeachDay, "First Flood");
let back = Progress::parse(&progress.to_text());
assert!(back.is_cleared(CampaignKind::TidePool, "Welcome Ashore"));
assert!(back.is_cleared(CampaignKind::BeachDay, "First Flood"));
assert_eq!(back.cleared_count(), 2);
let salvaged = Progress::parse("garbage\ncleared:\ncleared: tide:Welcome Ashore\n");
assert!(salvaged.is_cleared(CampaignKind::TidePool, "Welcome Ashore"));
assert_eq!(salvaged.cleared_count(), 1);
}
#[test]
fn the_record_survives_the_disk() {
let dir = std::env::temp_dir().join(format!("pinch-progress-{}", std::process::id()));
let path = dir.join("progress.txt");
assert_eq!(load_in(&path).cleared_count(), 0, "nothing saved yet");
let mut progress = Progress::default();
progress.mark(CampaignKind::TidePool, "Welcome Ashore");
save_in(&path, &progress).expect("saved");
let back = load_in(&path);
assert!(back.is_cleared(CampaignKind::TidePool, "Welcome Ashore"));
assert_eq!(back.to_text(), progress.to_text());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_cleared_stage_is_marked_where_the_campaign_stands() {
let mut app = App::new();
let mut on = campaign(10, Vec::new());
on.index = 4;
let name = on.current().name.clone();
app.insert_resource(on);
app.init_resource::<Progress>();
app.add_systems(Update, record_cleared);
app.update();
app.update();
let progress = app.world().resource::<Progress>();
assert!(progress.is_cleared(CampaignKind::TidePool, &name));
assert!(!progress.is_cleared(CampaignKind::BeachDay, &name));
assert_eq!(progress.cleared_count(), 1);
}
}