Skip to main content

pinch_points/app/
campaign.rs

1//! The level list a puzzle run walks, and the player-made levels that join
2//! the built-in ones.
3
4use crate::app::editor;
5use crate::sim::{Level, LevelKind};
6use bevy::prelude::*;
7
8/// Which level list the player is running.
9#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum CampaignKind {
11    TidePool,
12    BeachDay,
13}
14
15impl CampaignKind {
16    /// Stable save-file key, so progress in one list is not progress in the
17    /// other.
18    pub fn key(self) -> &'static str {
19        match self {
20            CampaignKind::TidePool => "tide",
21            CampaignKind::BeachDay => "beach",
22        }
23    }
24}
25
26#[derive(Resource)]
27pub struct Campaign {
28    pub kind: CampaignKind,
29    pub levels: Vec<Level>,
30    pub index: usize,
31    /// How many leading entries of `levels` ship with the game. The rest are
32    /// the player's own, which the stage list never locks.
33    pub builtins: usize,
34}
35
36impl Campaign {
37    pub fn current(&self) -> &Level {
38        &self.levels[self.index]
39    }
40
41    /// Swap in a fresh level list and start from its first level.
42    /// A whole struct literal, so a new field cannot survive a reset unseen.
43    pub(crate) fn reset(&mut self, kind: CampaignKind, levels: Vec<Level>, builtins: usize) {
44        let builtins = builtins.min(levels.len());
45        *self = Campaign {
46            kind,
47            levels,
48            index: 0,
49            builtins,
50        };
51    }
52}
53
54/// The Tide Pool list: the shipped campaign, then whatever the player has
55/// built, with the count of shipped levels (the ones the stage list gates).
56pub(crate) fn tide_pool_levels() -> (Vec<Level>, usize) {
57    let mut levels = crate::sim::campaign_levels();
58    let builtins = levels.len();
59    levels.extend(custom_puzzles(load_custom_levels()));
60    disambiguate(&mut levels, builtins);
61    (levels, builtins)
62}
63
64/// Give every level on the list a name of its own. Progress is filed by
65/// name, and so are the translated names and the hints, so a player's
66/// puzzle called "Welcome Ashore" would share the shipped one's tick, its
67/// French name and its hint. The shipped list is unique already (a test
68/// below says so); the player's levels are renamed on the way in, in
69/// list order, with a count after the name: the file on disk keeps the
70/// name it was saved under, only the list reads it apart.
71pub(crate) fn disambiguate(levels: &mut [Level], builtins: usize) {
72    let mut taken: std::collections::HashSet<String> = levels[..builtins.min(levels.len())]
73        .iter()
74        .map(|level| level.name.clone())
75        .collect();
76    for level in levels.iter_mut().skip(builtins) {
77        if taken.insert(level.name.clone()) {
78            continue;
79        }
80        let renamed = (2..)
81            .map(|n| format!("{} ({n})", level.name))
82            .find(|candidate| !taken.contains(candidate))
83            .unwrap_or_else(|| level.name.clone());
84        taken.insert(renamed.clone());
85        level.name = renamed;
86    }
87}
88
89/// The player's levels that are stages: the ones they built as puzzles, and
90/// that have somebody to route. A beach they built for a match is not a
91/// stage with an unusual number of castles, and putting it on the list made
92/// the campaign end on someone's versus arena.
93pub(crate) fn custom_puzzles(levels: Vec<Level>) -> Vec<Level> {
94    levels
95        .into_iter()
96        .filter(|level| level.kind == LevelKind::Puzzle && level.crab_count() > 0)
97        .collect()
98}
99
100/// The player's levels that are beaches to fight over. The seat count is
101/// the map dial's to check: a two-castle arena is a beach, just not one a
102/// table of four can sit at.
103pub(crate) fn custom_arenas(levels: Vec<Level>) -> Vec<Level> {
104    levels
105        .into_iter()
106        .filter(|level| level.kind == LevelKind::Arena)
107        .collect()
108}
109
110/// Everything on the player's shelf: the editor's old save slot plus
111/// anything dropped into `levels/custom/` under the XDG data directory.
112/// Unparseable files are skipped with a log line rather than breaking
113/// startup; which list a level joins is [`Level::kind`]'s to say.
114pub(crate) fn load_custom_levels() -> Vec<Level> {
115    let mut paths = vec![editor::legacy_save_path()];
116    if let Ok(dir) = std::fs::read_dir(editor::custom_dir()) {
117        let mut extra: Vec<_> = dir
118            .filter_map(Result::ok)
119            .map(|e| e.path())
120            .filter(|p| p.extension().is_some_and(|ext| ext == "txt"))
121            .collect();
122        extra.sort();
123        paths.extend(extra);
124    }
125    let mut levels = Vec::new();
126    for path in paths {
127        let Ok(text) = std::fs::read_to_string(&path) else {
128            continue; // absent is normal (nothing saved yet)
129        };
130        match Level::parse(&text) {
131            Ok(level) => {
132                info!(
133                    "loaded custom {} {:?} from {}",
134                    level.kind.token(),
135                    level.name,
136                    path.display()
137                );
138                // Said here, beside the line that read the file, and once
139                // per read: the filters below are asked the same question
140                // by two screens and would answer it twice over.
141                if level.kind == LevelKind::Puzzle && level.crab_count() == 0 {
142                    warn!("{}: a puzzle with no crabs to route", path.display());
143                }
144                if level.kind == LevelKind::Arena && level.seats() < 2 {
145                    warn!(
146                        "{}: a beach with castles for {} - no table can sit at it",
147                        path.display(),
148                        level.seats()
149                    );
150                }
151                levels.push(level);
152            }
153            Err(e) => warn!("skipping {}: {e}", path.display()),
154        }
155    }
156    levels
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn level(name: &str, kind: &str, crabs: bool) -> Level {
164        let crab = if crabs { "crab: 0,0 R L common\n" } else { "" };
165        Level::parse(&format!(
166            "name: {name}\nposts: 2\nkind: {kind}\n{crab}map:\n+-+-+-+\n|0 . 1|\n+-+-+-+\n"
167        ))
168        .expect("a level")
169    }
170
171    /// The shelf splits by what the author chose, not by what the board
172    /// looks like: both of these have two castles and only one is a beach.
173    #[test]
174    fn the_shelf_splits_by_kind() {
175        let shelf = || {
176            vec![
177                level("Stage", "puzzle", true),
178                level("Beach", "arena", true),
179                level("Empty Beach", "arena", false),
180                level("Crabless", "puzzle", false),
181            ]
182        };
183        let names = |levels: Vec<Level>| -> Vec<String> {
184            levels.into_iter().map(|level| level.name).collect()
185        };
186        assert_eq!(names(custom_puzzles(shelf())), ["Stage"]);
187        // A beach fed only by holes has no crab standing on it at the
188        // start, which is not a reason to keep it off the map dial.
189        assert_eq!(names(custom_arenas(shelf())), ["Beach", "Empty Beach"]);
190    }
191
192    /// The names progress is filed under have to be one each on the
193    /// shipped lists, or two stages would share a tick.
194    #[test]
195    fn shipped_level_names_are_unique() {
196        for levels in [
197            crate::sim::campaign_levels(),
198            crate::sim::challenge_levels(),
199        ] {
200            let mut seen = std::collections::HashSet::new();
201            for level in &levels {
202                assert!(seen.insert(level.name.clone()), "{:?} twice", level.name);
203            }
204        }
205    }
206
207    /// A player's level named like a shipped one, or like another of
208    /// theirs, is told apart on the list, so it does not inherit the
209    /// other's cleared tick. The shipped ones are left alone.
210    #[test]
211    fn same_named_player_levels_are_told_apart() {
212        let mut levels = vec![
213            level("Welcome Ashore", "puzzle", true),
214            level("Gull Alley", "puzzle", true),
215            level("Welcome Ashore", "puzzle", true),
216            level("Mine", "puzzle", true),
217            level("Mine", "puzzle", true),
218            level("Mine", "puzzle", true),
219            level("Mine (2)", "puzzle", true),
220        ];
221        disambiguate(&mut levels, 2);
222        let names: Vec<&str> = levels.iter().map(|level| level.name.as_str()).collect();
223        assert_eq!(
224            names,
225            [
226                "Welcome Ashore",
227                "Gull Alley",
228                "Welcome Ashore (2)",
229                "Mine",
230                "Mine (2)",
231                "Mine (3)",
232                "Mine (2) (2)",
233            ]
234        );
235    }
236}