pinch-points 0.8.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before time runs out
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
//! The level list a puzzle run walks, and the player-made levels that join
//! the built-in ones.

use crate::app::editor;
use crate::app::i18n::Lang;
use crate::sim::{Level, LevelKind};
use bevy::prelude::*;

/// Which level list the player is running.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum CampaignKind {
    TidePool,
    BeachDay,
}

impl CampaignKind {
    /// Stable save-file key, so progress in one list is not progress in the
    /// other.
    pub fn key(self) -> &'static str {
        match self {
            CampaignKind::TidePool => "tide",
            CampaignKind::BeachDay => "beach",
        }
    }

    /// The list a save-file key names, if this build has it.
    pub fn from_key(key: &str) -> Option<CampaignKind> {
        [CampaignKind::TidePool, CampaignKind::BeachDay]
            .into_iter()
            .find(|kind| kind.key() == key)
    }
}

/// Whether the Tide Pool is being played by two, side by side.
///
/// Co-op is a second pair of hands, not a second player: both cursors place
/// and lift the same seat's arrows, so the stage's inventory is one pool the
/// two of them share and either can re-point or pick up any arrow on the
/// beach. Every stage is proved solvable with exactly its arrows, whoever
/// puts them down, so all hundred work as they are.
///
/// Tide Pool only. Beach Day's rule evicts the oldest arrow to make room,
/// and "the oldest of a shared pool" is a rule nobody would guess.
#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)]
pub struct Coop(pub bool);

impl Coop {
    /// Whether co-op applies to the list being played.
    pub fn in_play(self, campaign: &Campaign) -> bool {
        self.0 && campaign.kind == CampaignKind::TidePool
    }
}

#[derive(Resource)]
pub struct Campaign {
    pub kind: CampaignKind,
    pub levels: Vec<Level>,
    pub index: usize,
    /// How many leading entries of `levels` ship with the game. The rest are
    /// the player's own, which the stage list never locks.
    pub builtins: usize,
}

impl Campaign {
    pub fn current(&self) -> &Level {
        &self.levels[self.index]
    }

    /// Where the current level sits in its own section, and whether that
    /// section is the player's own: their levels are a shelf behind the
    /// shipped list rather than stage a hundred and one, so both the
    /// hundredth stage and the first driftwood read as one of their own
    /// count. The stage list draws the two sections apart the same way.
    pub fn place(&self) -> (usize, usize, bool) {
        match self.index < self.builtins {
            true => (self.index + 1, self.builtins, false),
            false => (
                self.index - self.builtins + 1,
                self.levels.len() - self.builtins,
                true,
            ),
        }
    }

    /// The name level `at` is shown under in `lang`.
    ///
    /// The translations are written for the shipped levels and keyed by
    /// their English names, on both lists at once, so only a shipped level
    /// is looked up. A player's level keeps the name they gave it, even
    /// one that happens to match a stage on the other list.
    pub fn shown_name(&self, at: usize, lang: Lang) -> &str {
        let name = &self.levels[at].name;
        match self.is_shipped(at) {
            true => lang.level_name(name),
            false => name,
        }
    }

    /// The teaching hint for level `at`, if it is a shipped level that has
    /// one. A player's level teaches what its author meant it to, which no
    /// hint was written for, so it borrows none on account of its name.
    pub fn hint(&self, at: usize, lang: Lang) -> Option<&'static str> {
        match self.is_shipped(at) {
            true => lang.level_hint(&self.levels[at].name),
            false => None,
        }
    }

    /// Whether level `at` ships with the game, rather than being one of
    /// the player's own behind it.
    fn is_shipped(&self, at: usize) -> bool {
        at < self.builtins
    }

    /// Whether the current level ends the run Enter is walking. Both
    /// sections end one: the shipped campaign stops at its last shipped
    /// stage rather than walking on into the player's own levels, and
    /// those stop at the end of the list.
    pub fn is_last(&self) -> bool {
        let (place, of, _) = self.place();
        place == of
    }

    /// Swap in a fresh level list and start from its first level.
    /// A whole struct literal, so a new field cannot survive a reset unseen.
    pub(crate) fn reset(&mut self, kind: CampaignKind, levels: Vec<Level>, builtins: usize) {
        let builtins = builtins.min(levels.len());
        *self = Campaign {
            kind,
            levels,
            index: 0,
            builtins,
        };
    }
}

/// The Tide Pool list: the shipped campaign, then whatever the player has
/// built, with the count of shipped levels (the ones the stage list gates).
pub(crate) fn tide_pool_levels() -> (Vec<Level>, usize) {
    let mut levels = crate::sim::campaign_levels();
    let builtins = levels.len();
    levels.extend(custom_puzzles(load_custom_levels()));
    disambiguate(&mut levels, builtins);
    (levels, builtins)
}

/// Give every level on the list a name of its own. Progress is filed by
/// the list and the name, so a player's puzzle called "Welcome Ashore"
/// would share the shipped one's tick. The shipped list is unique
/// already; the player's levels are renamed on the way in, in list order,
/// with a count after the name, and the file on disk keeps the name it was
/// saved under.
///
/// Only this list's names are reserved. A puzzle of the player's called
/// "First Flood", like a Beach Day stage, shares no tick with it, since
/// the two are filed under different lists; and it is not shown under
/// that stage's translated name or offered its hint, because those are
/// only looked up for shipped levels (see [`Campaign::shown_name`]).
/// Renaming it would cost the player the tick it has earned under its own
/// name.
pub(crate) fn disambiguate(levels: &mut [Level], builtins: usize) {
    let mut taken: std::collections::HashSet<String> = levels[..builtins.min(levels.len())]
        .iter()
        .map(|level| level.name.clone())
        .collect();
    for level in levels.iter_mut().skip(builtins) {
        if taken.insert(level.name.clone()) {
            continue;
        }
        // Claiming the name is the test for it.
        let renamed = (2..)
            .find_map(|n| {
                let candidate = format!("{} ({n})", level.name);
                taken.insert(candidate.clone()).then_some(candidate)
            })
            .unwrap_or_else(|| level.name.clone());
        level.name = renamed;
    }
}

/// The player's levels that are stages: the ones they built as puzzles, and
/// that have somebody to route. A beach built for a match is not a stage
/// with an unusual number of castles, and on the list it ends the
/// campaign.
pub fn custom_puzzles(levels: Vec<Level>) -> Vec<Level> {
    levels
        .into_iter()
        .filter(|level| level.kind == LevelKind::Puzzle && level.crab_count() > 0)
        .collect()
}

/// The player's levels that are beaches to fight over. The seat count is
/// the map dial's to check: a two-castle arena is a beach, just not one a
/// table of four can sit at.
pub fn custom_arenas(levels: Vec<Level>) -> Vec<Level> {
    levels
        .into_iter()
        .filter(|level| level.kind == LevelKind::Arena)
        .collect()
}

/// Everything on the player's shelf: the editor's old save slot plus
/// anything dropped into `levels/custom/` under the XDG data directory.
/// Unparseable files are skipped with a log line rather than breaking
/// startup; which list a level joins is [`Level::kind`]'s to say.
pub(crate) fn load_custom_levels() -> Vec<Level> {
    load_custom_levels_in(&editor::legacy_save_path(), &editor::custom_dir())
}

/// [`load_custom_levels`] with both places named outright: the old single
/// save slot and the shelf directory. The real ones hang off the data
/// directory, read from the process environment, which a test cannot change
/// without racing every other test in the binary.
pub fn load_custom_levels_in(legacy: &std::path::Path, custom: &std::path::Path) -> Vec<Level> {
    let mut paths = vec![legacy.to_path_buf()];
    if let Ok(dir) = std::fs::read_dir(custom) {
        let mut extra: Vec<_> = dir
            .filter_map(Result::ok)
            .map(|e| e.path())
            .filter(|p| p.extension().is_some_and(|ext| ext == "txt"))
            .collect();
        extra.sort();
        paths.extend(extra);
    }
    let mut levels = Vec::new();
    for path in paths {
        let Ok(text) = std::fs::read_to_string(&path) else {
            continue; // absent is normal (nothing saved yet)
        };
        match Level::parse(&text) {
            Ok(level) => {
                info!(
                    "loaded custom {} {:?} from {}",
                    level.kind.token(),
                    level.name,
                    path.display()
                );
                // Said here, beside the line that read the file, and once
                // per read: the filters below are asked the same question
                // by two screens and would answer it twice over.
                if level.kind == LevelKind::Puzzle && level.crab_count() == 0 {
                    warn!("{}: a puzzle with no crabs to route", path.display());
                }
                if level.kind == LevelKind::Arena && level.seats() < 2 {
                    warn!(
                        "{}: a beach with castles for {} - no table can sit at it",
                        path.display(),
                        level.seats()
                    );
                }
                levels.push(level);
            }
            Err(e) => warn!("skipping {}: {e}", path.display()),
        }
    }
    levels
}

#[cfg(test)]
mod tests {
    use super::*;

    fn level(name: &str, kind: &str, crabs: bool) -> Level {
        let crab = if crabs { "crab: 0,0 R L common\n" } else { "" };
        Level::parse(&format!(
            "name: {name}\nposts: 2\nkind: {kind}\n{crab}map:\n+-+-+-+\n|0 . 1|\n+-+-+-+\n"
        ))
        .expect("a level")
    }

    /// The shelf splits by what the author chose, not by what the board
    /// looks like: both of these have two castles and only one is a beach.
    #[test]
    fn the_shelf_splits_by_kind() {
        let shelf = || {
            vec![
                level("Stage", "puzzle", true),
                level("Beach", "arena", true),
                level("Empty Beach", "arena", false),
                level("Crabless", "puzzle", false),
            ]
        };
        let names = |levels: Vec<Level>| -> Vec<String> {
            levels.into_iter().map(|level| level.name).collect()
        };
        assert_eq!(names(custom_puzzles(shelf())), ["Stage"]);
        // A beach fed only by holes has no crab standing on it at the
        // start, which is not a reason to keep it off the map dial.
        assert_eq!(names(custom_arenas(shelf())), ["Beach", "Empty Beach"]);
    }

    /// The names progress is filed under have to be one each on the
    /// shipped lists, or two stages would share a tick.
    #[test]
    fn shipped_level_names_are_unique() {
        for levels in [
            crate::sim::campaign_levels(),
            crate::sim::challenge_levels(),
        ] {
            let mut seen = std::collections::HashSet::new();
            for level in &levels {
                assert!(seen.insert(level.name.clone()), "{:?} twice", level.name);
            }
        }
    }

    /// A player's level named like a shipped one, or like another of
    /// theirs, is told apart on the list, so it does not inherit the
    /// other's cleared tick. The shipped ones are left alone.
    #[test]
    fn same_named_player_levels_are_told_apart() {
        let mut levels = vec![
            level("Welcome Ashore", "puzzle", true),
            level("Gull Alley", "puzzle", true),
            level("Welcome Ashore", "puzzle", true),
            level("Mine", "puzzle", true),
            level("Mine", "puzzle", true),
            level("Mine", "puzzle", true),
            level("Mine (2)", "puzzle", true),
        ];
        disambiguate(&mut levels, 2);
        let names: Vec<&str> = levels.iter().map(|level| level.name.as_str()).collect();
        assert_eq!(
            names,
            [
                "Welcome Ashore",
                "Gull Alley",
                "Welcome Ashore (2)",
                "Mine",
                "Mine (2)",
                "Mine (3)",
                "Mine (2) (2)",
            ]
        );
    }

    /// A Tide Pool level of the player's named like a Beach Day stage keeps
    /// its name, and with it the tick progress filed under that name: the
    /// two lists file progress apart. It is shown under its own name all
    /// the same, untranslated and with no hint, since those are only for
    /// the shipped levels, and so is a Tide Pool level of theirs whose
    /// name a shipped one's translation happens to be keyed by.
    #[test]
    fn a_player_level_named_like_a_beach_day_stage_keeps_its_name() {
        let beach_day = crate::sim::challenge_levels()[0].name.clone();
        let tide_pool = crate::sim::campaign_levels()[0].name.clone();
        let mut levels = vec![
            level(&tide_pool, "puzzle", true),
            level(&beach_day, "puzzle", true),
        ];
        disambiguate(&mut levels, 1);
        assert_eq!(levels[0].name, tide_pool, "the shipped one is left alone");
        assert_eq!(levels[1].name, beach_day, "and so is the player's");

        let lang = Lang::Fr;
        assert_ne!(
            lang.level_name(&beach_day),
            beach_day,
            "the Beach Day stage has a French name for this to be about"
        );
        let campaign = Campaign {
            kind: CampaignKind::TidePool,
            levels,
            index: 0,
            builtins: 1,
        };
        assert_eq!(campaign.shown_name(1, lang), beach_day, "untranslated");
        assert_eq!(campaign.hint(1, lang), None, "and with no hint");
        assert_eq!(
            campaign.shown_name(0, lang),
            lang.level_name(&tide_pool),
            "the shipped stage is still translated"
        );
        assert_eq!(campaign.hint(0, lang), lang.level_hint(&tide_pool));

        let mut progress = crate::app::progress::Progress::default();
        progress.mark(CampaignKind::TidePool, &beach_day);
        assert!(progress.is_cleared(CampaignKind::TidePool, &beach_day));
        assert!(
            !progress.is_cleared(CampaignKind::BeachDay, &beach_day),
            "the stage it is named like is not ticked by it"
        );
    }

    /// A scratch folder of its own, gone before and after.
    fn scratch(name: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!("pinch-shelf-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).expect("scratch dir");
        dir
    }

    /// The shelf reads the old save slot first and then every `.txt` in the
    /// folder in name order, skipping what is not a level and what is not a
    /// text file, rather than failing the whole shelf over one bad file.
    #[test]
    fn the_shelf_reads_its_folder_in_order_and_skips_what_it_cannot_read() {
        let dir = scratch("read");
        let text = |name: &str| level(name, "puzzle", true).to_text();
        std::fs::write(dir.join("slot.txt"), text("Old Slot")).expect("write");
        let shelf = dir.join("custom");
        std::fs::create_dir_all(&shelf).expect("shelf");
        std::fs::write(shelf.join("b.txt"), text("Second")).expect("write");
        std::fs::write(shelf.join("a.txt"), text("First")).expect("write");
        std::fs::write(shelf.join("c.txt"), "not a level at all").expect("write");
        std::fs::write(shelf.join("d.png"), text("Not A Text File")).expect("write");
        let names: Vec<String> = load_custom_levels_in(&dir.join("slot.txt"), &shelf)
            .into_iter()
            .map(|level| level.name)
            .collect();
        assert_eq!(names, ["Old Slot", "First", "Second"]);
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// Nothing saved yet, anywhere, is an empty shelf rather than an error.
    #[test]
    fn a_first_run_has_an_empty_shelf() {
        let dir = scratch("empty");
        assert!(load_custom_levels_in(&dir.join("absent.txt"), &dir.join("absent")).is_empty());
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// Co-op is the Tide Pool's alone: on Beach Day the switch changes
    /// nothing, and off it is off everywhere.
    #[test]
    fn coop_applies_to_the_tide_pool_only() {
        let (levels, builtins) = (crate::sim::campaign_levels(), 3);
        let mut campaign = Campaign {
            kind: CampaignKind::TidePool,
            levels,
            index: 0,
            builtins,
        };
        assert!(Coop(true).in_play(&campaign));
        assert!(!Coop(false).in_play(&campaign));
        campaign.kind = CampaignKind::BeachDay;
        assert!(!Coop(true).in_play(&campaign));
    }

    /// A reset starts the new list from its first level, and a shipped
    /// count longer than the list is held to the list.
    #[test]
    fn a_reset_starts_over_and_holds_the_shipped_count_to_the_list() {
        let mut campaign = Campaign {
            kind: CampaignKind::TidePool,
            levels: crate::sim::campaign_levels(),
            index: 7,
            builtins: 100,
        };
        let two = vec![level("One", "puzzle", true), level("Two", "puzzle", true)];
        campaign.reset(CampaignKind::BeachDay, two, 9);
        assert_eq!(campaign.kind, CampaignKind::BeachDay);
        assert_eq!(campaign.index, 0);
        assert_eq!(campaign.builtins, 2);
        assert_eq!(campaign.current().name, "One");
    }
}