use crate::app::editor;
use crate::app::i18n::Lang;
use crate::sim::{Level, LevelKind};
use bevy::prelude::*;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum CampaignKind {
TidePool,
BeachDay,
}
impl CampaignKind {
pub fn key(self) -> &'static str {
match self {
CampaignKind::TidePool => "tide",
CampaignKind::BeachDay => "beach",
}
}
pub fn from_key(key: &str) -> Option<CampaignKind> {
[CampaignKind::TidePool, CampaignKind::BeachDay]
.into_iter()
.find(|kind| kind.key() == key)
}
}
#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)]
pub struct Coop(pub bool);
impl Coop {
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,
pub builtins: usize,
}
impl Campaign {
pub fn current(&self) -> &Level {
&self.levels[self.index]
}
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,
),
}
}
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,
}
}
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,
}
}
fn is_shipped(&self, at: usize) -> bool {
at < self.builtins
}
pub fn is_last(&self) -> bool {
let (place, of, _) = self.place();
place == of
}
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,
};
}
}
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)
}
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;
}
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;
}
}
pub fn custom_puzzles(levels: Vec<Level>) -> Vec<Level> {
levels
.into_iter()
.filter(|level| level.kind == LevelKind::Puzzle && level.crab_count() > 0)
.collect()
}
pub fn custom_arenas(levels: Vec<Level>) -> Vec<Level> {
levels
.into_iter()
.filter(|level| level.kind == LevelKind::Arena)
.collect()
}
pub(crate) fn load_custom_levels() -> Vec<Level> {
load_custom_levels_in(&editor::legacy_save_path(), &editor::custom_dir())
}
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; };
match Level::parse(&text) {
Ok(level) => {
info!(
"loaded custom {} {:?} from {}",
level.kind.token(),
level.name,
path.display()
);
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")
}
#[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"]);
assert_eq!(names(custom_arenas(shelf())), ["Beach", "Empty Beach"]);
}
#[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);
}
}
}
#[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)",
]
);
}
#[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"
);
}
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
}
#[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);
}
#[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);
}
#[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));
}
#[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");
}
}