pinch-points 0.6.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before the tide comes in
Documentation
//! The level list a puzzle run walks, and the player-made levels that join
//! the built-in ones.

use crate::app::editor;
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,
            ),
        }
    }

    /// 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
/// name, and so are the translated names and the hints, so a player's
/// puzzle called "Welcome Ashore" would share the shipped one's tick, its
/// French name and its hint. 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.
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 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");
    }
}