pinch_points/app/
progress.rs1use 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(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 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 pub fn cleared_count(&self) -> usize {
47 self.cleared.values().map(HashSet::len).sum()
48 }
49
50 pub fn clear_all(&mut self) {
54 self.cleared.clear();
55 }
56
57 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 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 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 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 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
142pub 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
150pub fn save(progress: &Progress) {
153 let _ = save_in(&save_path(), progress);
154}
155
156pub fn save_in(path: &std::path::Path, progress: &Progress) -> std::io::Result<()> {
159 crate::app::paths::write_atomic(path, progress.to_text())
160}
161
162pub 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 #[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 #[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 #[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 #[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 #[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 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}