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