mod errors;
mod hexoban_move_characters;
mod octoban_move_characters;
mod parser;
mod sokoban_move_characters;
mod solving_mode;
mod step;
mod trioban_move_characters;
#[allow(clippy::all, clippy::pedantic, clippy::restriction, clippy::nursery)]
mod grammar;
use std::ops::Index;
pub use errors::{SavedGameSyntaxError, SavedGameSyntaxErrorKind};
use parser::Parser;
pub use solving_mode::SolvingMode;
pub use step::Step;
use crate::Tessellation;
#[derive(Debug, Clone, PartialEq)]
pub struct SavedGame {
solving_mode: SolvingMode,
steps: Vec<Step>,
tessellation: Tessellation,
pub title: String,
pub solved_by: String,
pub notes: String,
pub current_move_idx: usize,
}
pub(crate) fn is_saved_game_line(line: &str) -> bool {
!line
.chars()
.all(|c| c.is_whitespace() || c.is_ascii_digit())
&& line.chars().all(|c| {
c.is_ascii_digit()
|| c.is_whitespace()
|| Parser::is_move_char(c)
|| Parser::is_push_char(c)
|| Parser::is_saved_game_marker(c)
|| (c == Parser::RLE_EOL)
|| (c == Parser::RLE_GROUP_START)
|| (c == Parser::RLE_GROUP_END)
})
}
impl SavedGame {
pub fn parsed(data: &str, tessellation: Tessellation) -> Result<Self, SavedGameSyntaxError> {
let (steps, is_reverse, current_idx) = Parser::parse(data, tessellation)?;
Ok(Self {
steps,
tessellation,
solving_mode: if is_reverse {
SolvingMode::Reverse
} else {
SolvingMode::Forward
},
current_move_idx: current_idx,
title: String::new(),
solved_by: String::new(),
notes: String::new(),
})
}
pub fn from_steps(
data: &[Step], tessellation: Tessellation, solving_mode: Option<SolvingMode>,
) -> Result<Self, SavedGameSyntaxError> {
let mut is_reverse = false;
for (idx, step) in data.iter().enumerate() {
if matches!(step, Step::Jump(_)) {
is_reverse = true;
}
if !tessellation.known_directions().contains(&step.direction()) {
return Err(SavedGameSyntaxError::new(
SavedGameSyntaxErrorKind::InvalidStepDirection {
input: *step,
tessellation,
position: idx,
},
));
}
}
Ok(Self {
steps: data.to_vec(),
tessellation,
solving_mode: if is_reverse {
SolvingMode::Reverse
} else {
solving_mode.unwrap_or_default()
},
current_move_idx: 0,
title: String::new(),
solved_by: String::new(),
notes: String::new(),
})
}
pub fn printed_steps(&self) -> String {
Parser::print(&self.steps, self.tessellation, self.solving_mode)
}
pub fn solving_mode(&self) -> SolvingMode { self.solving_mode }
pub fn tessellation(&self) -> Tessellation { self.tessellation }
pub fn steps(&self) -> &[Step] { &self.steps }
pub fn len(&self) -> usize { self.steps.len() }
pub fn is_empty(&self) -> bool { self.steps.is_empty() }
pub fn moves_count(&self) -> usize {
self.steps
.iter()
.filter(|s| matches!(s, Step::Move(_)))
.count()
}
pub fn pushes_count(&self) -> usize {
self.steps
.iter()
.filter(|s| matches!(s, Step::PushOrPull(_)))
.count()
}
}
impl Index<usize> for SavedGame {
type Output = Step;
fn index(&self, index: usize) -> &Self::Output { &self.steps[index] }
}
#[cfg(feature = "serde")]
mod serde_impl {
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::{SavedGame, SolvingMode};
use crate::Tessellation;
#[derive(Serialize, Deserialize)]
#[serde(rename = "SavedGame", deny_unknown_fields)]
struct SavedGameRepr {
tessellation: Tessellation,
moves: String,
#[serde(default)]
title: String,
#[serde(default)]
solved_by: String,
#[serde(default)]
notes: String,
#[serde(default)]
current_move_idx: Option<usize>,
}
impl Serialize for SavedGame {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
SavedGameRepr {
tessellation: self.tessellation,
moves: self.printed_steps(),
title: self.title.clone(),
solved_by: self.solved_by.clone(),
notes: self.notes.clone(),
current_move_idx: Some(self.current_move_idx),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SavedGame {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let repr = SavedGameRepr::deserialize(deserializer)?;
let mut retv = if repr.moves.trim().is_empty() {
SavedGame::from_steps(&[], repr.tessellation, Some(SolvingMode::Forward))
.map_err(D::Error::custom)?
} else {
SavedGame::parsed(&repr.moves, repr.tessellation).map_err(D::Error::custom)?
};
retv.title = repr.title;
retv.solved_by = repr.solved_by;
retv.notes = repr.notes;
if let Some(current_move_idx) = repr.current_move_idx {
retv.current_move_idx = current_move_idx;
}
Ok(retv)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Direction, Tessellation};
fn jump_steps(saved_game: &SavedGame) -> usize {
saved_game
.steps()
.iter()
.filter(|s| matches!(s, Step::Jump(_)))
.count()
}
#[test]
fn it_can_be_constructed_from_char_data() -> Result<(), SavedGameSyntaxError> {
let saved_game: SavedGame = SavedGame::parsed("lurdLURD", Tessellation::Sokoban)?;
assert_eq!(saved_game.len(), 8);
assert_eq!(saved_game.moves_count(), 4);
assert_eq!(saved_game.pushes_count(), 4);
assert_eq!(jump_steps(&saved_game), 0);
Ok(())
}
#[test]
fn it_correctly_recognizes_reverse_mode_saved_games_in_char_data()
-> Result<(), SavedGameSyntaxError> {
let saved_game: SavedGame = SavedGame::parsed("lurdLURD", Tessellation::Sokoban)?;
assert_eq!(saved_game.solving_mode(), SolvingMode::Forward);
let saved_game: SavedGame = SavedGame::parsed("[]", Tessellation::Sokoban)?;
assert_eq!(saved_game.solving_mode(), SolvingMode::Reverse);
assert_eq!(jump_steps(&saved_game), 0);
let saved_game: SavedGame = SavedGame::parsed("lurd[]", Tessellation::Sokoban)?;
assert_eq!(saved_game.solving_mode(), SolvingMode::Reverse);
assert_eq!(jump_steps(&saved_game), 0);
let saved_game: SavedGame = SavedGame::parsed("l[u]rd[]", Tessellation::Sokoban)?;
assert_eq!(saved_game.solving_mode(), SolvingMode::Reverse);
assert_eq!(jump_steps(&saved_game), 1);
Ok(())
}
#[test]
fn it_can_be_constructed_from_steps() -> Result<(), SavedGameSyntaxError> {
let moves = vec![
Step::PushOrPull(Direction::Left),
Step::Move(Direction::Up),
Step::PushOrPull(Direction::Right),
Step::Jump(Direction::Down),
];
let saved_game: SavedGame = SavedGame::from_steps(&moves, Tessellation::Sokoban, None)?;
assert_eq!(saved_game.moves_count(), 1);
assert_eq!(saved_game.pushes_count(), 2);
assert_eq!(jump_steps(&saved_game), 1);
Ok(())
}
#[test]
fn when_constructed_from_steps_may_use_default_solving_mode() -> Result<(), SavedGameSyntaxError>
{
let moves = vec![
Step::PushOrPull(Direction::Left),
Step::Move(Direction::Up),
Step::PushOrPull(Direction::Right),
];
let saved_game: SavedGame = SavedGame::from_steps(&moves, Tessellation::Sokoban, None)?;
assert_eq!(saved_game.solving_mode, SolvingMode::Forward);
Ok(())
}
#[test]
fn when_constructed_from_steps_may_use_solving_mode_argument()
-> Result<(), SavedGameSyntaxError> {
let moves = vec![
Step::PushOrPull(Direction::Left),
Step::Move(Direction::Up),
Step::PushOrPull(Direction::Right),
];
let saved_game: SavedGame =
SavedGame::from_steps(&moves, Tessellation::Sokoban, Some(SolvingMode::Reverse))?;
assert_eq!(saved_game.solving_mode, SolvingMode::Reverse);
Ok(())
}
#[test]
fn when_constructed_from_steps_may_ignore_solving_mode_argument()
-> Result<(), SavedGameSyntaxError> {
let moves = vec![
Step::PushOrPull(Direction::Left),
Step::Move(Direction::Up),
Step::PushOrPull(Direction::Right),
Step::Jump(Direction::Right),
];
let saved_game: SavedGame =
SavedGame::from_steps(&moves, Tessellation::Sokoban, Some(SolvingMode::Forward))?;
assert_eq!(saved_game.solving_mode, SolvingMode::Reverse);
Ok(())
}
#[test]
fn it_can_convert_steps_to_string() -> Result<(), SavedGameSyntaxError> {
let saved_game: SavedGame = SavedGame::parsed("lurdllurd", Tessellation::Sokoban)?;
let converted = saved_game.printed_steps();
assert_eq!(converted, "lurdllurd");
let saved_game: SavedGame = SavedGame::parsed("lur{dllu}rd", Tessellation::Sokoban)?;
let converted = saved_game.printed_steps();
assert_eq!(converted, "lur{dllu}rd");
let saved_game: SavedGame = SavedGame::parsed("lur[dllu]rd", Tessellation::Sokoban)?;
let converted = saved_game.printed_steps();
assert_eq!(converted, "[]lur[dllu]rd");
Ok(())
}
#[test]
fn when_converting_steps_to_string_ensures_start_jump_for_reverse_mode_saved_games()
-> Result<(), SavedGameSyntaxError> {
let moves = vec![
Step::PushOrPull(Direction::Left),
Step::Move(Direction::Up),
Step::PushOrPull(Direction::Right),
];
let saved_game: SavedGame =
SavedGame::from_steps(&moves, Tessellation::Sokoban, Some(SolvingMode::Reverse))?;
let converted = saved_game.printed_steps();
assert_eq!(converted, "[]LuR");
let moves = vec![
Step::Jump(Direction::Left),
Step::PushOrPull(Direction::Left),
Step::Move(Direction::Up),
Step::PushOrPull(Direction::Right),
];
let saved_game: SavedGame =
SavedGame::from_steps(&moves, Tessellation::Sokoban, Some(SolvingMode::Reverse))?;
let converted = saved_game.printed_steps();
assert_eq!(converted, "[l]LuR");
Ok(())
}
#[cfg(feature = "serde")]
mod serde {
use std::error::Error;
use super::*;
fn round_tripped(saved_game: &SavedGame) -> Result<SavedGame, Box<dyn Error>> {
let json = serde_json::to_string(saved_game)?;
Ok(serde_json::from_str::<SavedGame>(&json)?)
}
#[test]
fn it_round_trips_through_json() -> Result<(), Box<dyn Error>> {
let cases = [
(Tessellation::Sokoban, "lurdLURD"),
(Tessellation::Hexoban, "llRRddsssSSSnNN"),
(Tessellation::Trioban, "nlLNrr"),
(Tessellation::Octoban, "lurdLURD"),
];
for (tessellation, moves) in cases {
let saved_game = SavedGame::parsed(moves, tessellation)?;
assert_eq!(round_tripped(&saved_game)?, saved_game, "{tessellation:?}");
}
Ok(())
}
#[test]
fn it_round_trips_pusher_changes() -> Result<(), Box<dyn Error>> {
let saved_game = SavedGame::parsed("lur{dllu}rd", Tessellation::Sokoban)?;
let parsed = round_tripped(&saved_game)?;
assert_eq!(parsed, saved_game);
assert_eq!(parsed.printed_steps(), "lur{dllu}rd");
Ok(())
}
#[test]
fn it_round_trips_reverse_mode_games() -> Result<(), Box<dyn Error>> {
let saved_game = SavedGame::parsed("[l]LuR", Tessellation::Sokoban)?;
assert_eq!(saved_game.solving_mode(), SolvingMode::Reverse);
let parsed = round_tripped(&saved_game)?;
assert_eq!(parsed, saved_game);
assert_eq!(parsed.solving_mode(), SolvingMode::Reverse);
Ok(())
}
#[test]
fn when_game_has_no_steps_solving_mode_still_round_trips() -> Result<(), Box<dyn Error>> {
for solving_mode in [SolvingMode::Forward, SolvingMode::Reverse] {
let saved_game =
SavedGame::from_steps(&[], Tessellation::Sokoban, Some(solving_mode))?;
let parsed = round_tripped(&saved_game)?;
assert_eq!(parsed, saved_game);
assert!(parsed.is_empty());
assert_eq!(parsed.solving_mode(), solving_mode);
}
Ok(())
}
#[test]
fn it_round_trips_metadata() -> Result<(), Box<dyn Error>> {
let mut saved_game = SavedGame::parsed("lurd", Tessellation::Sokoban)?;
saved_game.title = "Best attempt".to_string();
saved_game.solved_by = "Tomislav".to_string();
saved_game.notes = "Not really solved".to_string();
saved_game.current_move_idx = 3;
let parsed = round_tripped(&saved_game)?;
assert_eq!(parsed.title, "Best attempt");
assert_eq!(parsed.solved_by, "Tomislav");
assert_eq!(parsed.notes, "Not really solved");
assert_eq!(parsed.current_move_idx, 3);
Ok(())
}
#[test]
fn when_only_moves_are_given_the_rest_defaults() -> Result<(), Box<dyn Error>> {
let saved_game: SavedGame =
serde_json::from_str(r#"{"tessellation": "Sokoban", "moves": "lurd"}"#)?;
assert_eq!(saved_game, SavedGame::parsed("lurd", Tessellation::Sokoban)?);
Ok(())
}
#[test]
fn when_current_move_idx_is_absent_the_moves_marker_wins() -> Result<(), Box<dyn Error>> {
let saved_game: SavedGame =
serde_json::from_str(r#"{"tessellation": "Sokoban", "moves": "lu*rd"}"#)?;
assert_eq!(saved_game.current_move_idx, 2);
assert_eq!(saved_game.len(), 4);
Ok(())
}
#[test]
fn when_current_move_idx_is_given_it_wins() -> Result<(), Box<dyn Error>> {
let saved_game: SavedGame = serde_json::from_str(
r#"{"tessellation": "Sokoban", "moves": "lu*rd", "current_move_idx": 0}"#,
)?;
assert_eq!(saved_game.current_move_idx, 0);
Ok(())
}
#[test]
fn it_rejects_a_solving_mode_field() {
let result = serde_json::from_str::<SavedGame>(
r#"{"tessellation": "Sokoban", "moves": "lurd", "solving_mode": "Reverse"}"#,
);
assert!(result.is_err());
}
#[test]
fn it_rejects_steps_illegal_for_the_tessellation() {
let result = serde_json::from_str::<SavedGame>(
r#"{"tessellation": "Sokoban", "moves": "lurdnw"}"#,
);
assert!(result.is_err());
}
}
}