1use crate::app::{Campaign, CampaignKind};
13use bevy::prelude::*;
14use std::collections::{HashMap, HashSet};
15
16fn save_path() -> std::path::PathBuf {
18 crate::app::paths::data_dir().join("progress.txt")
19}
20
21#[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 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#[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 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 pub fn cleared_count(&self) -> usize {
66 self.cleared.values().map(HashSet::len).sum()
67 }
68
69 pub fn clear_all(&mut self) {
73 self.cleared.clear();
74 }
75
76 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 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 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 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 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
160pub 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
168pub fn save(progress: &Progress) {
171 let _ = save_in(&save_path(), progress);
172}
173
174pub fn save_in(path: &std::path::Path, progress: &Progress) -> std::io::Result<()> {
177 crate::app::paths::write_atomic(path, progress.to_text())
178}
179
180pub 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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}