Skip to main content

pinch_points/app/
progress.rs

1//! Which stages the player has cleared, and therefore which are still
2//! locked. The original's stage list only opened the next stage once you
3//! beat the one before it.
4//!
5//! Progress is keyed by campaign kind and level *name*, not by index, so
6//! inserting a level into the middle of the campaign does not silently
7//! re-lock everything after it. Names are therefore unique on the list:
8//! a player's level that shares a shipped one's name on its list is
9//! renamed as it is loaded (see [`crate::app::campaign::disambiguate`]), or the two would
10//! share one tick.
11
12use crate::app::{Campaign, CampaignKind};
13use bevy::prelude::*;
14use std::collections::{HashMap, HashSet};
15
16/// The cleared-stage record, under the XDG data directory.
17fn save_path() -> std::path::PathBuf {
18    crate::app::paths::data_dir().join("progress.txt")
19}
20
21/// A campaign and how far up it the player has climbed: which stage is
22/// current, and which are open to them.
23#[derive(bevy::ecs::system::SystemParam)]
24pub struct Ladder<'w> {
25    pub campaign: ResMut<'w, Campaign>,
26    pub progress: Res<'w, Progress>,
27}
28
29impl Ladder<'_> {
30    /// Make stage `at` the current one if it is open, answering whether it
31    /// was. A locked stage leaves the campaign where it stood.
32    pub fn climb_to(&mut self, at: usize) -> bool {
33        let open = self.progress.unlocked(&self.campaign, at);
34        if open {
35            self.campaign.index = at;
36        }
37        open
38    }
39}
40
41/// Kept as one set of names per list rather than one set of `kind:name`
42/// keys: the stage list asks after every tile every frame, and a key built
43/// per lookup is a hundred-odd allocations a frame.
44#[derive(Resource, Default)]
45pub struct Progress {
46    cleared: HashMap<CampaignKind, HashSet<String>>,
47}
48
49impl Progress {
50    pub fn is_cleared(&self, kind: CampaignKind, name: &str) -> bool {
51        self.cleared
52            .get(&kind)
53            .is_some_and(|names| names.contains(name))
54    }
55
56    /// Records a clear; true when this was the first time.
57    pub fn mark(&mut self, kind: CampaignKind, name: &str) -> bool {
58        self.cleared
59            .entry(kind)
60            .or_default()
61            .insert(name.to_string())
62    }
63
64    /// How many stages are cleared, over both lists.
65    pub fn cleared_count(&self) -> usize {
66        self.cleared.values().map(HashSet::len).sum()
67    }
68
69    /// Forget every cleared stage, both lists at once: they share this one
70    /// record, and half a reset would leave Beach Day open on a beach the
71    /// player just asked to start over.
72    pub fn clear_all(&mut self) {
73        self.cleared.clear();
74    }
75
76    /// The classic rule: the first stage is always open, so is any stage
77    /// you have already cleared, so is the one right after a cleared stage.
78    /// Player-made levels sit past the built-ins and are never locked:
79    /// they are yours, and the editor plays them anyway.
80    pub fn unlocked(&self, campaign: &Campaign, index: usize) -> bool {
81        if index == 0 || index >= campaign.builtins {
82            return true;
83        }
84        let cleared = |at: usize| {
85            campaign
86                .levels
87                .get(at)
88                .is_some_and(|level| self.is_cleared(campaign.kind, &level.name))
89        };
90        cleared(index) || cleared(index - 1)
91    }
92
93    /// How many of this list's stages are cleared, over one shelf of it.
94    /// The stage select counts the shipped campaign and the player's own
95    /// levels separately: they are two lists on one screen.
96    pub fn cleared_in(&self, campaign: &Campaign, range: std::ops::Range<usize>) -> usize {
97        campaign
98            .levels
99            .get(range)
100            .unwrap_or_default()
101            .iter()
102            .filter(|level| self.is_cleared(campaign.kind, &level.name))
103            .count()
104    }
105
106    /// The furthest stage that is open to play, for resuming the list.
107    pub fn furthest_open(&self, campaign: &Campaign) -> usize {
108        (0..campaign.levels.len())
109            .filter(|&index| self.unlocked(campaign, index))
110            .take_while(|&index| index < campaign.builtins)
111            .last()
112            .unwrap_or(0)
113    }
114
115    pub fn to_text(&self) -> String {
116        // Destructured with no rest pattern: a new field refuses to build
117        // here until it is written out. `parse` is lenient and would never
118        // notice one going unsaved.
119        let Self { cleared } = self;
120        let mut keys: Vec<String> = cleared
121            .iter()
122            .flat_map(|(kind, names)| {
123                names
124                    .iter()
125                    .map(move |name| format!("{}:{name}", kind.key()))
126            })
127            .collect();
128        keys.sort_unstable();
129        keys.iter().fold(String::new(), |mut out, key| {
130            out.push_str("cleared: ");
131            out.push_str(key);
132            out.push('\n');
133            out
134        })
135    }
136
137    /// Lenient parse, in the style of the other save files: anything that is
138    /// not a `cleared:` line naming a list this build has is ignored rather
139    /// than fatal.
140    pub fn parse(text: &str) -> Progress {
141        let mut progress = Progress::default();
142        for line in text.lines() {
143            if let Some((key, value)) = line.split_once(':')
144                && key.trim() == "cleared"
145                && let Some((kind, name)) = value.trim().split_once(':')
146                && let Some(kind) = CampaignKind::from_key(kind)
147                && !name.is_empty()
148            {
149                progress.mark(kind, name);
150            }
151        }
152        progress
153    }
154}
155
156pub fn load(mut commands: Commands) {
157    commands.insert_resource(load_in(&save_path()));
158}
159
160/// The record read back from a file named outright; a missing or
161/// unreadable one is a fresh start, as it is on a first run.
162pub fn load_in(path: &std::path::Path) -> Progress {
163    std::fs::read_to_string(path)
164        .map(|text| Progress::parse(&text))
165        .unwrap_or_default()
166}
167
168/// Write the record out. Best-effort, like the other save files: a disk that
169/// will not take it is not worth interrupting play over.
170pub fn save(progress: &Progress) {
171    let _ = save_in(&save_path(), progress);
172}
173
174/// [`save`] to a file named outright, and with the error kept: the game
175/// shrugs it off, a test wants to see it.
176pub fn save_in(path: &std::path::Path, progress: &Progress) -> std::io::Result<()> {
177    crate::app::paths::write_atomic(path, progress.to_text())
178}
179
180/// A cleared stage, saved the moment it is cleared: the stage list is the
181/// player's record of the campaign, and a crash should not cost it.
182pub fn record_cleared(campaign: Res<Campaign>, mut progress: ResMut<Progress>) {
183    let name = campaign.current().name.clone();
184    if progress.mark(campaign.kind, &name) {
185        save(&progress);
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::sim::{Level, campaign_levels};
193
194    fn campaign(builtins: usize, extra: Vec<Level>) -> Campaign {
195        let mut levels = campaign_levels();
196        levels.truncate(builtins);
197        let builtins = levels.len();
198        levels.extend(extra);
199        Campaign {
200            kind: CampaignKind::TidePool,
201            levels,
202            index: 0,
203            builtins,
204        }
205    }
206
207    /// One stage open at the start, and clearing a stage opens exactly one
208    /// more: the ladder the original's stage list walks.
209    #[test]
210    fn clearing_a_stage_opens_the_next_one_only() {
211        let campaign = campaign(5, vec![]);
212        let mut progress = Progress::default();
213        assert!(progress.unlocked(&campaign, 0));
214        for index in 1..5 {
215            assert!(!progress.unlocked(&campaign, index), "stage {index}");
216        }
217
218        let first = campaign.levels[0].name.clone();
219        assert!(progress.mark(CampaignKind::TidePool, &first));
220        assert!(!progress.mark(CampaignKind::TidePool, &first), "idempotent");
221        assert!(progress.unlocked(&campaign, 1));
222        assert!(!progress.unlocked(&campaign, 2), "no further than that");
223        assert_eq!(progress.cleared_in(&campaign, 0..campaign.levels.len()), 1);
224        assert_eq!(progress.furthest_open(&campaign), 1);
225    }
226
227    /// The two lists keep separate records, even for a shared level name.
228    #[test]
229    fn the_two_campaigns_do_not_share_progress() {
230        let mut progress = Progress::default();
231        progress.mark(CampaignKind::TidePool, "Gull Storm");
232        assert!(progress.is_cleared(CampaignKind::TidePool, "Gull Storm"));
233        assert!(!progress.is_cleared(CampaignKind::BeachDay, "Gull Storm"));
234    }
235
236    /// A level the player built is never locked behind the campaign.
237    #[test]
238    fn player_made_levels_are_always_open() {
239        let mine =
240            Level::parse(include_str!("../../levels/01_welcome_ashore.txt")).expect("parses");
241        let campaign = campaign(5, vec![mine]);
242        let progress = Progress::default();
243        assert!(
244            !progress.unlocked(&campaign, 4),
245            "still behind the campaign"
246        );
247        assert!(progress.unlocked(&campaign, 5), "the player's own level");
248    }
249
250    /// A record from a build with a list this one lacks keeps what it can
251    /// read: the unknown list is dropped rather than fatal, and the known
252    /// one is not lost with it.
253    #[test]
254    fn a_list_this_build_lacks_is_ignored() {
255        let mixed = Progress::parse("cleared: reef:Far Shoal\ncleared: tide:Welcome Ashore\n");
256        assert!(mixed.is_cleared(CampaignKind::TidePool, "Welcome Ashore"));
257        assert_eq!(mixed.cleared_count(), 1);
258        assert_eq!(CampaignKind::from_key("reef"), None);
259        assert_eq!(
260            CampaignKind::from_key("beach"),
261            Some(CampaignKind::BeachDay)
262        );
263    }
264
265    /// The file is written in one order whatever order the stages were
266    /// cleared in, list first and then name, so two saves of the same
267    /// record are the same bytes.
268    #[test]
269    fn the_record_is_written_in_one_order() {
270        let mut progress = Progress::default();
271        progress.mark(CampaignKind::TidePool, "Undertow");
272        progress.mark(CampaignKind::TidePool, "Corner Pocket");
273        progress.mark(CampaignKind::BeachDay, "First Flood");
274        assert_eq!(
275            progress.to_text(),
276            "cleared: beach:First Flood\ncleared: tide:Corner Pocket\ncleared: tide:Undertow\n"
277        );
278        let mut reversed = Progress::default();
279        reversed.mark(CampaignKind::BeachDay, "First Flood");
280        reversed.mark(CampaignKind::TidePool, "Corner Pocket");
281        reversed.mark(CampaignKind::TidePool, "Undertow");
282        assert_eq!(reversed.to_text(), progress.to_text());
283    }
284
285    #[test]
286    fn progress_survives_a_save_and_load() {
287        let mut progress = Progress::default();
288        progress.mark(CampaignKind::TidePool, "Welcome Ashore");
289        progress.mark(CampaignKind::BeachDay, "First Flood");
290        let back = Progress::parse(&progress.to_text());
291        assert!(back.is_cleared(CampaignKind::TidePool, "Welcome Ashore"));
292        assert!(back.is_cleared(CampaignKind::BeachDay, "First Flood"));
293        assert_eq!(back.cleared_count(), 2);
294        // Junk lines are ignored, not fatal.
295        let salvaged = Progress::parse("garbage\ncleared:\ncleared: tide:Welcome Ashore\n");
296        assert!(salvaged.is_cleared(CampaignKind::TidePool, "Welcome Ashore"));
297        assert_eq!(salvaged.cleared_count(), 1);
298    }
299
300    /// The record goes to disk and comes back the same, and a file that is
301    /// not there is a fresh start rather than a failure.
302    #[test]
303    fn the_record_survives_the_disk() {
304        let dir = std::env::temp_dir().join(format!("pinch-progress-{}", std::process::id()));
305        let path = dir.join("progress.txt");
306        assert_eq!(load_in(&path).cleared_count(), 0, "nothing saved yet");
307        let mut progress = Progress::default();
308        progress.mark(CampaignKind::TidePool, "Welcome Ashore");
309        save_in(&path, &progress).expect("saved");
310        let back = load_in(&path);
311        assert!(back.is_cleared(CampaignKind::TidePool, "Welcome Ashore"));
312        assert_eq!(back.to_text(), progress.to_text());
313        let _ = std::fs::remove_dir_all(dir);
314    }
315
316    /// Clearing a stage marks the stage the campaign is on, in its own
317    /// list, and only once however often the win is seen.
318    #[test]
319    fn a_cleared_stage_is_marked_where_the_campaign_stands() {
320        let mut app = App::new();
321        let mut on = campaign(10, Vec::new());
322        on.index = 4;
323        let name = on.current().name.clone();
324        app.insert_resource(on);
325        app.init_resource::<Progress>();
326        app.add_systems(Update, record_cleared);
327        app.update();
328        app.update();
329        let progress = app.world().resource::<Progress>();
330        assert!(progress.is_cleared(CampaignKind::TidePool, &name));
331        assert!(!progress.is_cleared(CampaignKind::BeachDay, &name));
332        assert_eq!(progress.cleared_count(), 1);
333    }
334}