use crate::app::editor;
use crate::app::i18n::Lang;
use crate::sim::{ArenaProblem, Level, LevelKind};
use bevy::prelude::*;
pub(crate) const KEY_LESSON_LEVEL: &str = "Welcome Ashore";
#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)]
pub struct Coop(pub bool);
#[derive(Resource)]
pub struct Campaign {
pub levels: Vec<Level>,
pub index: usize,
pub builtins: usize,
pub coop: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Section {
TidePool,
Coop,
Custom,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Place {
pub at: usize,
pub of: usize,
pub section: Section,
}
#[cfg(test)]
impl Campaign {
pub fn tide_pool() -> Self {
Self::tide_pool_of(crate::sim::campaign_levels())
}
pub fn tide_pool_of(levels: Vec<Level>) -> Self {
Campaign {
builtins: levels.len(),
levels,
index: 0,
coop: 0,
}
}
}
impl Campaign {
pub fn current(&self) -> &Level {
&self.levels[self.index]
}
pub fn place(&self) -> Place {
self.place_of(self.index)
}
pub fn place_of(&self, at: usize) -> Place {
let section = self.section_of(at);
let range = self.section(section);
Place {
at: at.saturating_sub(range.start) + 1,
of: range.len(),
section,
}
}
pub fn section_of(&self, at: usize) -> Section {
let builtins = self.builtins.min(self.levels.len());
let coop = self.coop.min(builtins);
if at >= builtins {
Section::Custom
} else if at >= builtins - coop {
Section::Coop
} else {
Section::TidePool
}
}
pub fn section(&self, section: Section) -> std::ops::Range<usize> {
let builtins = self.builtins.min(self.levels.len());
let coop = self.coop.min(builtins);
match section {
Section::TidePool => 0..builtins - coop,
Section::Coop => builtins - coop..builtins,
Section::Custom => builtins..self.levels.len(),
}
}
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,
}
}
fn hint(&self, at: usize, lang: Lang) -> Option<String> {
let own = match self.is_shipped(at) {
true => lang.level_hint(&self.levels[at].name),
false => None,
};
let coop = self.levels.get(at).is_some_and(Level::is_coop);
match (own, coop) {
(Some(hint), _) => Some(hint.to_string()),
(None, true) => Some(crate::app::name_the_pair(lang.tr(), lang.tr().coop_rule)),
(None, false) => None,
}
}
pub fn shown_hint(
&self,
at: usize,
settings: &crate::app::settings::GameSettings,
coop: Coop,
device: crate::app::gamepad::LastDevice,
) -> Option<String> {
let hint = self.hint(at, settings.language)?;
let names_keys = self.levels[at].name == KEY_LESSON_LEVEL;
let keys_as_named =
settings.stock_legend(coop.0) && device == crate::app::gamepad::LastDevice::Keyboard;
(!names_keys || keys_as_named).then_some(hint)
}
pub fn needs_partner(&self, at: usize, coop: Coop) -> bool {
!coop.0 && self.levels.get(at).is_some_and(Level::is_coop)
}
fn is_shipped(&self, at: usize) -> bool {
at < self.builtins
}
pub(crate) fn is_from_a_code(&self, at: usize) -> bool {
!self.is_shipped(at)
&& self
.levels
.get(at)
.is_some_and(|level| level.filing.is_none())
}
pub(crate) fn holds_a_code(&self) -> bool {
(0..self.levels.len()).any(|at| self.is_from_a_code(at))
}
pub fn is_last(&self) -> bool {
let Place { at, of, .. } = self.place();
at == of
}
pub(crate) fn reset(&mut self, fresh: Campaign) {
let Campaign {
levels,
index: _,
builtins,
coop,
} = fresh;
let builtins = builtins.min(levels.len());
*self = Campaign {
levels,
index: 0,
builtins,
coop: coop.min(builtins),
};
}
}
pub(crate) fn stage_list() -> Campaign {
let mut levels = crate::sim::campaign_levels();
let coop = crate::sim::coop_levels();
let coops = coop.len();
levels.extend(coop);
let builtins = levels.len();
levels.extend(custom_puzzles(load_custom_levels()));
disambiguate(&mut levels, builtins);
Campaign {
levels,
index: 0,
builtins,
coop: coops,
}
}
pub(crate) fn disambiguate(levels: &mut [Level], builtins: usize) {
let builtins = builtins.min(levels.len());
let mut taken: std::collections::HashSet<String> = levels[..builtins]
.iter()
.map(|level| level.name.clone())
.collect();
let mut sharing: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for level in &levels[builtins..] {
*sharing.entry(level.name.clone()).or_default() += 1;
}
for level in levels.iter_mut().skip(builtins) {
let original = level.name.clone();
if !taken.insert(level.name.clone()) {
level.name = (2..)
.find_map(|n| {
let candidate = format!("{} ({n})", level.name);
taken.insert(candidate.clone()).then_some(candidate)
})
.unwrap_or_else(|| level.name.clone());
}
let alone = sharing.get(&original) == Some(&1)
&& (level.name == original || !sharing.contains_key(&level.name));
if let Some(filing) = level.filing.as_mut() {
filing.legacy = alone.then(|| level.name.clone());
}
}
}
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 && level.board().feeds_crabs())
.collect()
}
pub(crate) fn load_custom_levels() -> Vec<Level> {
load_custom_levels_in(&editor::legacy_save_path(), &editor::custom_dir())
}
fn filing_key(legacy: &std::path::Path, path: &std::path::Path) -> String {
let file = path
.file_name()
.map_or_else(String::new, |name| name.to_string_lossy().into_owned());
match path == legacy {
true => format!("file:{file}"),
false => format!("file:custom/{file}"),
}
}
pub(crate) fn filed_path(level: &Level) -> Option<std::path::PathBuf> {
let key = &level.filing.as_ref()?.key;
let file = key.strip_prefix("file:")?;
Some(match file.strip_prefix("custom/") {
Some(file) => editor::custom_dir().join(file),
None => editor::legacy_save_path(),
})
}
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(mut level) => {
level.filing = Some(crate::sim::Filing {
key: filing_key(legacy, &path),
legacy: None,
});
info!(
"loaded custom {} {:?} from {}",
level.kind.token(),
level.name,
path.display()
);
match level.kind {
LevelKind::Arena => match level.arena_problem() {
Some(ArenaProblem::SkippedSeat(_)) => warn!(
"{}: a beach whose castles skip a player - no table can sit at it",
path.display()
),
Some(ArenaProblem::TooFew(seats)) => warn!(
"{}: a beach with castles for {seats} - no table can sit at it",
path.display()
),
None if !level.board().feeds_crabs() => warn!(
"{}: a beach with no crabs and no holes - nothing to fight over",
path.display()
),
None => {}
},
LevelKind::Puzzle if level.crab_count() == 0 => {
warn!("{}: a puzzle with no crabs to route", path.display());
}
LevelKind::Puzzle => {}
}
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")
}
fn hole_fed(name: &str) -> Level {
Level::parse(&format!(
"name: {name}\nposts: 2\nkind: arena\nspawner: 1,0 R 60\nmap:\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),
hole_fed("Hole Beach"),
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", "Hole Beach"]);
}
#[test]
fn shipped_level_names_are_unique() {
let mut seen = std::collections::HashSet::new();
for level in &crate::sim::campaign_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)",
]
);
}
fn listed(dir: &std::path::Path) -> Campaign {
let mut levels = crate::sim::campaign_levels();
levels.truncate(2);
levels.extend(custom_puzzles(load_custom_levels_in(
&dir.join("slot.txt"),
&dir.join("custom"),
)));
disambiguate(&mut levels, 2);
Campaign {
builtins: 2,
..Campaign::tide_pool_of(levels)
}
}
#[test]
fn a_tick_stays_with_its_file_when_a_namesake_sorts_ahead() {
use crate::app::progress::Progress;
let dir = crate::app::paths::test_dir("shelf-namesakes");
let shelf = dir.join("custom");
std::fs::create_dir_all(&shelf).expect("shelf");
let text = |name: &str| level(name, "puzzle", true).to_text();
std::fs::write(shelf.join("b.txt"), text("Twins")).expect("write");
std::fs::write(shelf.join("c.txt"), text("Twins")).expect("write");
let before = listed(&dir);
let cleared = before
.levels
.iter()
.position(|level| level.name == "Twins (2)")
.expect("the second is told apart");
let mut progress = Progress::default();
progress.mark_level(&before.levels[cleared]);
std::fs::write(shelf.join("a.txt"), text("Twins")).expect("write");
let after = listed(&dir);
let ticked: Vec<&str> = after
.levels
.iter()
.filter(|level| progress.level_cleared(level))
.map(|level| level.progress_key())
.collect();
assert_eq!(
ticked,
["file:custom/c.txt"],
"still the file that was cleared"
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_tick_filed_by_name_is_adopted_where_the_name_is_unambiguous() {
use crate::app::progress::Progress;
let dir = crate::app::paths::test_dir("shelf-legacy");
let shelf = dir.join("custom");
std::fs::create_dir_all(&shelf).expect("shelf");
let text = |name: &str| level(name, "puzzle", true).to_text();
std::fs::write(shelf.join("a.txt"), text("Alone")).expect("write");
std::fs::write(shelf.join("b.txt"), text("Pair")).expect("write");
std::fs::write(shelf.join("c.txt"), text("Pair")).expect("write");
let campaign = listed(&dir);
let mut progress = Progress::default();
for name in ["Alone", "Pair", "Pair (2)"] {
progress.mark(name);
}
assert!(progress.adopt_legacy(&campaign), "something moved");
assert!(!progress.adopt_legacy(&campaign), "and only once");
let ticked = |key: &str| {
let level = campaign
.levels
.iter()
.find(|level| level.progress_key() == key)
.expect("on the list");
progress.level_cleared(level)
};
assert!(ticked("file:custom/a.txt"), "the only Alone");
assert!(!ticked("file:custom/b.txt"), "either Pair could have");
assert!(!ticked("file:custom/c.txt"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_shelf_reads_its_folder_in_order_and_skips_what_it_cannot_read() {
let dir = crate::app::paths::test_dir("shelf-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 = crate::app::paths::test_dir("shelf-empty");
assert!(load_custom_levels_in(&dir.join("absent.txt"), &dir.join("absent")).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_reset_starts_over_and_holds_the_shipped_count_to_the_list() {
let mut campaign = Campaign {
index: 7,
builtins: 100,
..Campaign::tide_pool()
};
let two = vec![level("One", "puzzle", true), level("Two", "puzzle", true)];
campaign.reset(Campaign {
builtins: 9,
coop: 12,
..Campaign::tide_pool_of(two)
});
assert_eq!(campaign.index, 0);
assert_eq!(campaign.builtins, 2);
assert_eq!(campaign.coop, 2);
assert_eq!(campaign.current().name, "One");
}
#[test]
fn the_key_lesson_is_shown_only_under_the_stock_keys() {
use crate::app::gamepad::LastDevice;
use crate::app::settings::{CommitScheme, GameSettings};
let levels = crate::sim::campaign_levels();
let at = levels
.iter()
.position(|level| level.name == KEY_LESSON_LEVEL)
.expect("the key lesson ships");
let campaign = Campaign::tide_pool_of(levels);
let stock = GameSettings::default();
assert!(
campaign
.shown_hint(at, &stock, Coop(false), LastDevice::Keyboard)
.is_some()
);
let one_hand = GameSettings {
commit: CommitScheme::Ijkl,
..GameSettings::default()
};
assert_eq!(
campaign.shown_hint(at, &one_hand, Coop(false), LastDevice::Keyboard),
None
);
assert!(
campaign
.shown_hint(at, &one_hand, Coop(true), LastDevice::Keyboard)
.is_some()
);
assert_eq!(
campaign.shown_hint(at, &stock, Coop(false), LastDevice::Pad),
None
);
}
#[test]
fn only_the_key_lesson_names_keys() {
let names_keys = |hint: &str| hint.contains("WASD") || hint.contains("arrow key");
assert!(names_keys(Lang::En.level_hint(KEY_LESSON_LEVEL).unwrap()));
for level in crate::sim::campaign_levels() {
if level.name != KEY_LESSON_LEVEL
&& let Some(hint) = Lang::En.level_hint(&level.name)
{
assert!(!names_keys(hint), "{:?} names keys too", level.name);
}
}
}
}