mod errors;
mod plus_id;
use std::str::FromStr;
use ahash::AHashMap;
use errors::SokobanPlusInputKind;
pub use errors::{SokobanPlusSyntaxError, SokobanPlusSyntaxErrorKind};
use itertools::Itertools;
pub use plus_id::PlusID;
use regex::Regex;
use crate::{BoxID, GoalID};
#[derive(Debug, Clone, PartialEq, Default)]
pub struct SokobanPlus {
boxorder: AHashMap<u16, u16>,
goalorder: AHashMap<u16, u16>,
}
impl SokobanPlus {
const EXPLICIT_NO_PLUS_ID: u16 = 0;
const LEGACY_EXPLICIT_NO_PLUS_ID: u16 = 99;
pub fn new() -> Self {
Self {
boxorder: AHashMap::default(),
goalorder: AHashMap::default(),
}
}
pub fn parsed(boxorder: &str, goalorder: &str) -> Result<Self, SokobanPlusSyntaxError> {
Self::parsed_impl(boxorder, goalorder, true)
}
pub fn parsed_legacy(boxorder: &str, goalorder: &str) -> Result<Self, SokobanPlusSyntaxError> {
Self::parsed_impl(boxorder, goalorder, false)
}
#[expect(clippy::map_err_ignore)]
fn parsed_impl(
boxorder: &str, goalorder: &str, treat_99_as_plus_id: bool,
) -> Result<Self, SokobanPlusSyntaxError> {
let boxorder =
Self::parse_ids(boxorder, SokobanPlusInputKind::Boxorder, treat_99_as_plus_id)?;
let goalorder =
Self::parse_ids(goalorder, SokobanPlusInputKind::Goalorder, treat_99_as_plus_id)?;
if boxorder.is_empty() && goalorder.is_empty() {
return Ok(Self::new());
}
let mut retv = SokobanPlus::new();
let mut process_ids = |data: Vec<Option<PlusID>>,
input_kind: SokobanPlusInputKind|
-> Result<(), SokobanPlusSyntaxError> {
for (idx, plus_id) in data.iter().enumerate() {
if input_kind == SokobanPlusInputKind::Boxorder {
let box_id = BoxID::try_from(idx).map_err(|_| {
SokobanPlusSyntaxError::new(
0,
input_kind,
SokobanPlusSyntaxErrorKind::TooManyPlusIDs,
None,
)
})?;
retv.set_box_plus_id(box_id, *plus_id);
} else {
let goal_id = GoalID::try_from(idx).map_err(|_| {
SokobanPlusSyntaxError::new(
0,
input_kind,
SokobanPlusSyntaxErrorKind::TooManyPlusIDs,
None,
)
})?;
retv.set_goal_plus_id(goal_id, *plus_id);
}
}
Ok(())
};
process_ids(boxorder, SokobanPlusInputKind::Boxorder)?;
process_ids(goalorder, SokobanPlusInputKind::Goalorder)?;
Ok(retv)
}
pub fn boxorder(&self) -> String {
let data = Self::pieces_order(&self.boxorder);
Self::print(&data)
}
pub fn goalorder(&self) -> String {
let data = Self::pieces_order(&self.goalorder);
Self::print(&data)
}
pub fn is_empty(&self) -> bool { self.boxorder.is_empty() && self.goalorder.is_empty() }
pub fn box_plus_ids(&self) -> Vec<Option<PlusID>> { Self::plus_ids(&self.boxorder) }
pub fn goal_plus_ids(&self) -> Vec<Option<PlusID>> { Self::plus_ids(&self.goalorder) }
pub fn get_box_plus_id(&self, box_id: BoxID) -> Option<PlusID> {
self.boxorder
.get(&box_id.into())
.map(|val| PlusID::try_from(*val).ok())?
}
pub fn get_goal_plus_id(&self, goal_id: GoalID) -> Option<PlusID> {
self.goalorder
.get(&goal_id.into())
.map(|val| PlusID::try_from(*val).ok())?
}
pub fn set_box_plus_id(&mut self, box_id: BoxID, box_plus_id: Option<PlusID>) {
if let Some(plus_id) = box_plus_id {
self.boxorder.insert(box_id.into(), plus_id.into());
} else {
self.boxorder.remove(&box_id.into());
}
}
pub fn set_goal_plus_id(&mut self, goal_id: GoalID, goal_plus_id: Option<PlusID>) {
if let Some(plus_id) = goal_plus_id {
self.goalorder.insert(goal_id.into(), plus_id.into());
} else {
self.goalorder.remove(&goal_id.into());
}
}
fn print(data: &[u16]) -> String { data.iter().join(" ") }
fn pieces_order(data: &AHashMap<u16, u16>) -> Vec<u16> {
match data.keys().max() {
None => Vec::new(),
Some(max_key) => {
(0..=*max_key)
.map(|key| data.get(&key).copied().unwrap_or(Self::EXPLICIT_NO_PLUS_ID))
.collect()
}
}
}
fn plus_ids(data: &AHashMap<u16, u16>) -> Vec<Option<PlusID>> {
match data.keys().max() {
None => Vec::new(),
Some(max_key) => {
(0..=*max_key)
.map(|key| {
data.get(&key)
.and_then(|value| PlusID::try_from(*value).ok())
})
.collect()
}
}
}
fn parse_ids(
data: &str, kind: SokobanPlusInputKind, treat_99_as_plus_id: bool,
) -> Result<Vec<Option<PlusID>>, SokobanPlusSyntaxError> {
let Ok(re) = Regex::new(r"[ \t,:;]+") else {
unreachable!("internal bug, failed to create Regex instance from valid regex")
};
let mut retv: Vec<Option<PlusID>> = Vec::default();
let mut last_end = 0;
for mat in re.find_iter(data) {
let start = mat.start();
let end = mat.end();
if last_end != start {
retv.push(Self::parse_and_normalize_plus_id(
&data[last_end..start],
kind,
treat_99_as_plus_id,
last_end,
)?);
}
last_end = end;
}
if last_end < data.len() {
retv.push(Self::parse_and_normalize_plus_id(
&data[last_end..],
kind,
treat_99_as_plus_id,
last_end,
)?);
}
Ok(retv)
}
fn parse_and_normalize_plus_id(
data: &str, input_kind: SokobanPlusInputKind, treat_99_as_plus_id: bool, pos: usize,
) -> Result<Option<PlusID>, SokobanPlusSyntaxError> {
let raw_plus_id: u16 = u16::from_str(data).map_err(|e| {
SokobanPlusSyntaxError::new(
pos,
input_kind,
SokobanPlusSyntaxErrorKind::ParseIntError,
Some(e),
)
})?;
let normalized_raw_plus_id =
if raw_plus_id == Self::LEGACY_EXPLICIT_NO_PLUS_ID && !treat_99_as_plus_id {
Self::EXPLICIT_NO_PLUS_ID
} else {
raw_plus_id
};
if normalized_raw_plus_id == Self::EXPLICIT_NO_PLUS_ID {
Ok(None)
} else {
match PlusID::new(normalized_raw_plus_id) {
Ok(new_plus_id) => Ok(Some(new_plus_id)),
Err(_) => {
unreachable!(
"internal bug, failed to create PlusID instance from already validated u16"
)
}
}
}
}
}
#[cfg(feature = "serde")]
mod serde_impl {
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::SokobanPlus;
use crate::{BoxID, GoalID, PlusID};
#[derive(Serialize, Deserialize)]
#[serde(rename = "SokobanPlus", deny_unknown_fields)]
struct SokobanPlusRepr {
#[serde(default)]
boxorder: Vec<Option<PlusID>>,
#[serde(default)]
goalorder: Vec<Option<PlusID>>,
}
impl Serialize for SokobanPlus {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
SokobanPlusRepr {
boxorder: self.box_plus_ids(),
goalorder: self.goal_plus_ids(),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SokobanPlus {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let repr = SokobanPlusRepr::deserialize(deserializer)?;
let mut retv = SokobanPlus::new();
for (position, plus_id) in repr.boxorder.into_iter().enumerate() {
if plus_id.is_some() {
let box_id = BoxID::try_from(position).map_err(D::Error::custom)?;
retv.set_box_plus_id(box_id, plus_id);
}
}
for (position, plus_id) in repr.goalorder.into_iter().enumerate() {
if plus_id.is_some() {
let goal_id = GoalID::try_from(position).map_err(D::Error::custom)?;
retv.set_goal_plus_id(goal_id, plus_id);
}
}
Ok(retv)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::PieceIdError;
#[test]
fn it_can_be_constructed_from_boxorder_and_goalorder() -> Result<(), SokobanPlusSyntaxError> {
let sokoban_plus = SokobanPlus::parsed("1 2 3 4 5 6", "6 5 4 3 2 1")?;
assert_eq!(sokoban_plus.boxorder(), "1 2 3 4 5 6");
assert_eq!(sokoban_plus.goalorder(), "6 5 4 3 2 1");
Ok(())
}
#[test]
fn it_correctly_persists_sokoban_plus_ids() -> Result<(), SokobanPlusSyntaxError> {
let sokoban_plus = SokobanPlus::parsed("1 1 1 2 2 3", "0 3 2 2 1 1 1")?;
assert_eq!(sokoban_plus.boxorder.get(&0), Some(&1));
assert_eq!(sokoban_plus.boxorder.get(&1), Some(&1));
assert_eq!(sokoban_plus.boxorder.get(&2), Some(&1));
assert_eq!(sokoban_plus.boxorder.get(&3), Some(&2));
assert_eq!(sokoban_plus.boxorder.get(&4), Some(&2));
assert_eq!(sokoban_plus.boxorder.get(&5), Some(&3));
assert_eq!(sokoban_plus.boxorder.get(&6), None);
assert_eq!(sokoban_plus.goalorder.get(&0), None);
assert_eq!(sokoban_plus.goalorder.get(&1), Some(&3));
assert_eq!(sokoban_plus.goalorder.get(&2), Some(&2));
assert_eq!(sokoban_plus.goalorder.get(&3), Some(&2));
assert_eq!(sokoban_plus.goalorder.get(&4), Some(&1));
assert_eq!(sokoban_plus.goalorder.get(&5), Some(&1));
assert_eq!(sokoban_plus.goalorder.get(&6), Some(&1));
Ok(())
}
#[test]
fn it_can_handle_legacy_default_plus_id() -> Result<(), SokobanPlusSyntaxError> {
let sokoban_plus = SokobanPlus::parsed_legacy("99 99 3", "99 3 99")?;
assert_eq!(sokoban_plus.boxorder.get(&0), None);
assert_eq!(sokoban_plus.boxorder.get(&1), None);
assert_eq!(sokoban_plus.boxorder.get(&2), Some(&3));
assert_eq!(sokoban_plus.boxorder.get(&3), None);
assert_eq!(sokoban_plus.goalorder.get(&0), None);
assert_eq!(sokoban_plus.goalorder.get(&1), Some(&3));
assert_eq!(sokoban_plus.goalorder.get(&2), None);
assert_eq!(sokoban_plus.goalorder.get(&3), None);
let sokoban_plus = SokobanPlus::parsed("99 99 3", "99 3 99")?;
assert_eq!(sokoban_plus.boxorder.get(&0), Some(&99));
assert_eq!(sokoban_plus.boxorder.get(&1), Some(&99));
assert_eq!(sokoban_plus.boxorder.get(&2), Some(&3));
assert_eq!(sokoban_plus.boxorder.get(&3), None);
assert_eq!(sokoban_plus.goalorder.get(&0), Some(&99));
assert_eq!(sokoban_plus.goalorder.get(&1), Some(&3));
assert_eq!(sokoban_plus.goalorder.get(&2), Some(&99));
assert_eq!(sokoban_plus.goalorder.get(&3), None);
Ok(())
}
#[test]
fn when_a_plus_id_is_99_it_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let mut sokoban_plus = SokobanPlus::new();
sokoban_plus.set_box_plus_id(BoxID::from(1_u8), Some(PlusID::try_from(99_u16)?));
assert_eq!(sokoban_plus.boxorder(), "0 99");
assert_eq!(SokobanPlus::parsed(&sokoban_plus.boxorder(), "")?, sokoban_plus);
assert!(SokobanPlus::parsed_legacy(&sokoban_plus.boxorder(), "")?.is_empty());
Ok(())
}
#[test]
fn it_allows_being_created_from_blank_strings() -> Result<(), SokobanPlusSyntaxError> {
let result = SokobanPlus::parsed("", "")?;
assert!(result.is_empty());
Ok(())
}
#[test]
fn it_setter_doesnt_treat_99_specially() -> Result<(), PieceIdError> {
let mut sokoban_plus = SokobanPlus::default();
let piece_id = BoxID::from(2_u8);
let plus_id = Some(PlusID::try_from(99_u16)?);
sokoban_plus.set_box_plus_id(piece_id, plus_id);
assert_eq!(sokoban_plus.boxorder.get(&2), Some(&99));
Ok(())
}
#[test]
fn it_prints_correct_boxorder_and_goalorder() -> Result<(), PieceIdError> {
let mut sokoban_plus = SokobanPlus::new();
let box_id = BoxID::from(1_u8);
let other_box_id = BoxID::from(4_u8);
let goal_id = GoalID::from(3_u8);
let plus_id = Some(PlusID::try_from(42_u16)?);
let other_plus_id = Some(PlusID::try_from(7_u16)?);
sokoban_plus.set_box_plus_id(box_id, plus_id);
sokoban_plus.set_box_plus_id(other_box_id, other_plus_id);
sokoban_plus.set_goal_plus_id(goal_id, plus_id);
let boxorder = sokoban_plus.boxorder();
let goalorder = sokoban_plus.goalorder();
assert_eq!(boxorder, "0 42 0 0 7");
assert_eq!(goalorder, "0 0 0 42");
Ok(())
}
#[test]
fn it_pads_interior_gaps_in_boxorder_and_goalorder() -> Result<(), SokobanPlusSyntaxError> {
let sokoban_plus = SokobanPlus::parsed("1 0 0 2", "0 1 0 2 0")?;
assert_eq!(sokoban_plus.boxorder(), "1 0 0 2");
assert_eq!(sokoban_plus.goalorder(), "0 1 0 2");
Ok(())
}
#[test]
fn it_round_trips_through_boxorder_and_goalorder() -> Result<(), SokobanPlusSyntaxError> {
let cases = [
("1 2 3", "3 2 1"), ("0 0 1 2", "1 2"), ("1 0 0 2", "2 0 0 1"), ("1 2 0 0", "0 0 2 1"), ("0 1 0 2 0", "0 2 0 1"), ("", "1 2"), ("", ""), ];
for (boxorder, goalorder) in cases {
let sokoban_plus = SokobanPlus::parsed(boxorder, goalorder)?;
let reparsed =
SokobanPlus::parsed(&sokoban_plus.boxorder(), &sokoban_plus.goalorder())?;
assert_eq!(reparsed, sokoban_plus, "failed for {boxorder:?} / {goalorder:?}");
}
Ok(())
}
#[test]
fn when_a_plus_id_is_cleared_the_later_pieces_keep_their_positions() -> Result<(), PieceIdError>
{
let mut sokoban_plus = SokobanPlus::new();
sokoban_plus.set_box_plus_id(BoxID::from(0_u8), Some(PlusID::try_from(1_u16)?));
sokoban_plus.set_box_plus_id(BoxID::from(1_u8), Some(PlusID::try_from(2_u16)?));
sokoban_plus.set_box_plus_id(BoxID::from(2_u8), Some(PlusID::try_from(3_u16)?));
sokoban_plus.set_box_plus_id(BoxID::from(1_u8), None);
assert_eq!(sokoban_plus.boxorder(), "1 0 3");
assert_eq!(sokoban_plus.get_box_plus_id(BoxID::from(2_u8)), PlusID::new(3).ok());
Ok(())
}
#[test]
fn when_there_are_no_plus_ids_the_order_is_blank() -> Result<(), PieceIdError> {
let mut sokoban_plus = SokobanPlus::new();
assert_eq!(sokoban_plus.boxorder(), "");
assert_eq!(sokoban_plus.goalorder(), "");
sokoban_plus.set_box_plus_id(BoxID::from(0_u8), Some(PlusID::try_from(1_u16)?));
assert_eq!(sokoban_plus.boxorder(), "1");
assert_eq!(sokoban_plus.goalorder(), "");
Ok(())
}
#[cfg(feature = "serde")]
mod serde {
use std::error::Error;
use super::*;
fn round_tripped(sokoban_plus: &SokobanPlus) -> Result<SokobanPlus, Box<dyn Error>> {
let json = serde_json::to_string(sokoban_plus)?;
Ok(serde_json::from_str::<SokobanPlus>(&json)?)
}
#[test]
fn it_round_trips_through_json() -> Result<(), Box<dyn Error>> {
let sokoban_plus = SokobanPlus::parsed("1 4 3 5", "5 3 4 1")?;
let parsed = round_tripped(&sokoban_plus)?;
assert_eq!(parsed, sokoban_plus);
assert_eq!(parsed.boxorder(), "1 4 3 5");
assert_eq!(parsed.goalorder(), "5 3 4 1");
Ok(())
}
#[test]
fn it_serializes_as_dense_arrays() -> Result<(), Box<dyn Error>> {
let mut sokoban_plus = SokobanPlus::new();
sokoban_plus.set_box_plus_id(BoxID::from(0_u8), Some(PlusID::new(1)?));
sokoban_plus.set_box_plus_id(BoxID::from(3_u8), Some(PlusID::new(2)?));
assert_eq!(
serde_json::to_string(&sokoban_plus)?,
r#"{"boxorder":[1,null,null,2],"goalorder":[]}"#
);
Ok(())
}
#[test]
fn it_preserves_interior_gaps() -> Result<(), Box<dyn Error>> {
let mut sokoban_plus = SokobanPlus::new();
sokoban_plus.set_box_plus_id(BoxID::from(0_u8), Some(PlusID::new(1)?));
sokoban_plus.set_box_plus_id(BoxID::from(3_u8), Some(PlusID::new(2)?));
let parsed = round_tripped(&sokoban_plus)?;
assert_eq!(parsed, sokoban_plus);
assert_eq!(parsed.get_box_plus_id(BoxID::from(3_u8)), PlusID::new(2).ok());
assert_eq!(parsed.get_box_plus_id(BoxID::from(1_u8)), None);
Ok(())
}
#[test]
fn it_preserves_a_plus_id_of_99() -> Result<(), Box<dyn Error>> {
let mut sokoban_plus = SokobanPlus::new();
sokoban_plus.set_box_plus_id(BoxID::from(1_u8), Some(PlusID::new(99)?));
let parsed = round_tripped(&sokoban_plus)?;
assert_eq!(parsed, sokoban_plus);
assert_eq!(parsed.get_box_plus_id(BoxID::from(1_u8)), PlusID::new(99).ok());
Ok(())
}
#[test]
fn when_sokoban_plus_is_empty_it_round_trips() -> Result<(), Box<dyn Error>> {
let sokoban_plus = SokobanPlus::new();
assert_eq!(
serde_json::to_string(&sokoban_plus)?,
r#"{"boxorder":[],"goalorder":[]}"#
);
assert!(round_tripped(&sokoban_plus)?.is_empty());
Ok(())
}
#[test]
fn when_orders_are_absent_they_default_to_empty() -> Result<(), Box<dyn Error>> {
let sokoban_plus: SokobanPlus = serde_json::from_str("{}")?;
assert!(sokoban_plus.is_empty());
Ok(())
}
#[test]
fn it_serializes_deterministically() -> Result<(), Box<dyn Error>> {
let sokoban_plus = SokobanPlus::parsed("1 4 3 5 0 2", "5 3 4 1 2 0")?;
assert_eq!(
serde_json::to_string(&sokoban_plus)?,
serde_json::to_string(&sokoban_plus)?
);
Ok(())
}
#[test]
fn it_rejects_a_plus_id_of_zero() {
let result = serde_json::from_str::<SokobanPlus>(r#"{"boxorder": [1, 0]}"#);
assert!(result.is_err());
}
#[test]
fn it_rejects_piece_indexes_out_of_range() -> Result<(), Box<dyn Error>> {
fn boxorder_of_len(len: usize) -> String {
let mut slots = std::iter::repeat_n("null", len - 1).chain(std::iter::once("1"));
format!(r#"{{"boxorder": [{}]}}"#, slots.join(","))
}
let largest_legal = usize::from(BoxID::MAX) + 1;
let sokoban_plus: SokobanPlus = serde_json::from_str(&boxorder_of_len(largest_legal))?;
assert_eq!(
sokoban_plus.get_box_plus_id(BoxID::try_from(BoxID::MAX)?),
PlusID::new(1).ok()
);
let result = serde_json::from_str::<SokobanPlus>(&boxorder_of_len(largest_legal + 1));
let err = result.expect_err("index past BoxID::MAX must be rejected");
assert!(
err.to_string()
.contains(&(usize::from(BoxID::MAX) + 1).to_string())
);
Ok(())
}
#[test]
fn it_rejects_unknown_fields() {
let result = serde_json::from_str::<SokobanPlus>(r#"{"boxes": [1, 2]}"#);
assert!(result.is_err());
}
}
}