diff --git a/Cargo.toml b/Cargo.toml index 11b4c7d..162fecf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ description = "A game engine Swiss-army knife" repository = "https://github.com/raklaptudirm/tetka" [dependencies] +strum = "0.26" tetka-uxi = { package = "uxi", path = "./uxi" } tetka-games = { path = "./games" } diff --git a/games/src/interface/move.rs b/games/src/interface/move.rs index 50f2ab9..904a721 100644 --- a/games/src/interface/move.rs +++ b/games/src/interface/move.rs @@ -42,7 +42,7 @@ pub trait MoveStore: Default { /// current limitations in the Rust type system, the current max capacity is /// capped at 256, which can be problematic for games which can have more moves /// in a position and might require a custom type. -pub type MoveList = ArrayVec; +pub type MoveList = ArrayVec; // MoveStore implementation for MoveList. impl MoveStore for MoveList { diff --git a/games/src/isolation/bitboard.rs b/games/src/isolation/bitboard.rs new file mode 100644 index 0000000..03b7e09 --- /dev/null +++ b/games/src/isolation/bitboard.rs @@ -0,0 +1,44 @@ +// Copyright © 2024 Rak Laptudirm +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::interface::bitboard_type; + +use super::Square; + +bitboard_type! { + /// A set of Squares implemented as a bitset where the `1 << sq.into()` bit + /// represents whether `sq` is in the BitBoard or not. + struct BitBoard : u64 { + // The BitBoard's Square type. + Square = Square; + + // BitBoards representing the null and the universe sets. + Empty = Self(0); + Universe = Self(0xffffffffffff); + + // BitBoards containing the squares of the first file and the first rank. + FirstFile = Self(0x0000010101010101); + FirstRank = Self(0x00000000000000ff); + } +} + +use crate::interface::{BitBoardType, RepresentableType}; + +impl BitBoard { + /// singles returns the targets of all singular moves from all the source + /// squares given in the provided BitBoard. + pub fn singles(bb: BitBoard) -> BitBoard { + let bar = bb | bb.east() | bb.west(); + (bar | bar.north() | bar.south()) ^ bb + } +} diff --git a/games/src/isolation/mod.rs b/games/src/isolation/mod.rs new file mode 100644 index 0000000..7d8a804 --- /dev/null +++ b/games/src/isolation/mod.rs @@ -0,0 +1,18 @@ +// Make the contents of the non-namespaced +// modules public, so they can be accessed +// without their parent namespace. +pub use self::bitboard::*; +pub use self::piece::*; +pub use self::position::*; +pub use self::r#move::*; +pub use self::square::*; + +// Non-namespaced modules. +mod bitboard; +mod r#move; +mod piece; +mod position; +mod square; + +#[cfg(test)] +mod tests; diff --git a/games/src/isolation/move.rs b/games/src/isolation/move.rs new file mode 100644 index 0000000..44dbc04 --- /dev/null +++ b/games/src/isolation/move.rs @@ -0,0 +1,169 @@ +// Copyright © 2024 Rak Laptudirm +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::fmt; +use std::str::FromStr; + +use thiserror::Error; + +use super::Square; +use crate::interface::{MoveType, RepresentableType, TypeParseError}; + +/// Move represents an Isolation move which can be played on the Board. +#[derive(Copy, Clone, PartialEq, Eq, Default)] +pub struct Move(u16); + +impl MoveType for Move { + const NULL: Self = Move(1 << 15); + const MAX_IN_GAME: usize = 48; + const MAX_IN_POSITION: usize = 352; +} + +impl From for Move { + fn from(value: u16) -> Self { + Move(value) + } +} + +impl From for u16 { + fn from(value: Move) -> Self { + value.0 + } +} + +impl Move { + // Bit-widths of fields. + const PAWN_WIDTH: u16 = 6; + const TILE_WIDTH: u16 = 6; + + // Bit-masks of fields. + const PAWN_MASK: u16 = (1 << Move::PAWN_WIDTH) - 1; + const TILE_MASK: u16 = (1 << Move::TILE_WIDTH) - 1; + + // Bit-offsets of fields. + const PAWN_OFFSET: u16 = 0; + const TILE_OFFSET: u16 = Move::PAWN_OFFSET + Move::PAWN_WIDTH; + + /// new returns a new jump Move from the given pawn Square to the given + /// tile Square. These Squares can be recovered with the [`Move::pawn`] and + /// [`Move::tile`] methods respectively. + /// ``` + /// use tetka_games::isolation::*; + /// + /// let mov = Move::new(Square::A1, Square::A3); + /// + /// assert_eq!(mov.pawn(), Square::A1); + /// assert_eq!(mov.tile(), Square::A3); + /// ``` + #[inline(always)] + #[rustfmt::skip] + pub fn new(pawn: Square, tile: Square) -> Move { + Move( + (pawn as u16) << Move::PAWN_OFFSET | + (tile as u16) << Move::TILE_OFFSET + ) + } + + /// Source returns the pawn Square of the moving piece. This is equal to the + /// tile Square if the given Move is of singular type. + /// ``` + /// use tetka_games::isolation::*; + /// + /// let mov = Move::new(Square::A1, Square::A3); + /// + /// assert_eq!(mov.pawn(), Square::A1); + /// ``` + pub fn pawn(self) -> Square { + unsafe { + Square::unsafe_from((self.0 >> Move::PAWN_OFFSET) & Move::PAWN_MASK) + } + } + + /// Target returns the tile Square of the moving piece. + /// ``` + /// use tetka_games::isolation::*; + /// + /// let mov = Move::new(Square::A1, Square::A3); + /// + /// assert_eq!(mov.tile(), Square::A3); + /// ``` + pub fn tile(self) -> Square { + unsafe { + Square::unsafe_from((self.0 >> Move::TILE_OFFSET) & Move::TILE_MASK) + } + } +} + +#[derive(Error, Debug)] +pub enum MoveParseError { + #[error("length of move string should be 2 or 4, not {0}")] + BadLength(usize), + #[error("bad pawn square string \"{0}\"")] + BadSquare(#[from] TypeParseError), +} + +impl FromStr for Move { + type Err = MoveParseError; + + /// from_str converts the given string representation of a Move into a [Move]. + /// The format supported is ``. For how `` and `` are + /// parsed, take a look at [`Square::FromStr`](Square::from_str). This function + /// can be treated as the inverse of the [`fmt::Display`] trait for [Move]. + /// ``` + /// use tetka_games::isolation::*; + /// use std::str::FromStr; + /// + /// let jump = Move::new(Square::A1, Square::A3); + /// assert_eq!(Move::from_str(&jump.to_string()).unwrap(), jump); + /// ``` + fn from_str(s: &str) -> Result { + if s.len() != 4 { + return Err(MoveParseError::BadLength(s.len())); + } + + let pawn = Square::from_str(&s[..2])?; + let tile = Square::from_str(&s[2..])?; + + Ok(Move::new(pawn, tile)) + } +} + +impl fmt::Display for Move { + /// Display formats the given Move in a human-readable manner. The format used + /// for displaying moves is ``. For the formatting of `` and + /// ``, refer to `Square::Display`. [`Move::NULL`] is formatted as `null`. + /// ``` + /// use tetka_games::isolation::*; + /// + /// let null = Move::NULL; + /// let jump = Move::new(Square::A1, Square::A3); + /// + /// assert_eq!(null.to_string(), "null"); + /// assert_eq!(jump.to_string(), "a1a3"); + /// ``` + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if *self == Move::NULL { + write!(f, "null") + } else { + write!(f, "{}{}", self.pawn(), self.tile()) + } + } +} + +impl fmt::Debug for Move { + /// Debug formats the given Move into a human-readable debug string. It uses + /// `Move::Display` trait under the hood for formatting the Move. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self) + } +} diff --git a/games/src/isolation/piece.rs b/games/src/isolation/piece.rs new file mode 100644 index 0000000..6a034f7 --- /dev/null +++ b/games/src/isolation/piece.rs @@ -0,0 +1,66 @@ +// Copyright © 2024 Rak Laptudirm +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::fmt; +use std::ops; +use std::str::FromStr; + +use crate::interface::representable_type; +use crate::interface::ColoredPieceType; +use crate::interface::RepresentableType; + +representable_type!( + /// Color represents all the possible colors that an ataxx piece can have, + /// specifically, Black and White. + enum Color: u8 { White "w", Black "b", } +); + +impl ops::Not for Color { + type Output = Color; + + /// not implements the not unary operator (!) which switches the current Color + /// to its opposite, i.e. [`Color::Black`] to [`Color::White`] and vice versa. + fn not(self) -> Self::Output { + unsafe { Color::unsafe_from(self as usize ^ 1) } + } +} + +representable_type!( + /// Piece represents the types of pieces in ataxx, namely Piece and Block. + enum Piece: u8 { Pawn "p", Tile "-", } +); + +representable_type!( + /// Piece represents all the possible ataxx pieces. + enum ColoredPiece: u8 { WhitePawn "P", BlackPawn "p", Tile "-", } +); + +impl ColoredPieceType for ColoredPiece { + type Piece = Piece; + type Color = Color; + + fn piece(self) -> Piece { + match self { + ColoredPiece::WhitePawn | ColoredPiece::BlackPawn => Piece::Pawn, + ColoredPiece::Tile => Piece::Tile, + } + } + + fn color(self) -> Color { + match self { + ColoredPiece::WhitePawn => Color::White, + ColoredPiece::BlackPawn => Color::Black, + _ => panic!("Piece::color() called on Piece::Tile"), + } + } +} diff --git a/games/src/isolation/position.rs b/games/src/isolation/position.rs new file mode 100644 index 0000000..0804951 --- /dev/null +++ b/games/src/isolation/position.rs @@ -0,0 +1,313 @@ +// Copyright © 2024 Rak Laptudirm +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::fmt; +use std::num::ParseIntError; +use std::str::FromStr; + +use strum::IntoEnumIterator; + +use crate::interface; +use crate::interface::ColoredPieceType; +use crate::interface::PiecePlacementParseError; +use crate::interface::PositionType; +use crate::interface::TypeParseError; +use crate::interface::{BitBoardType, Hash, RepresentableType, SquareType}; + +use thiserror::Error; + +#[rustfmt::skip] +use super::{ + BitBoard, ColoredPiece, File, Move, + Rank, Square, Color, Piece +}; +use crate::interface::MoveStore; + +/// Position represents the snapshot of an Isolation Board, the state of the an +/// Isolation game at a single point in time. It also provides all of the methods +/// necessary to manipulate such a snapshot. +#[derive(Copy, Clone)] +pub struct Position { + pub pawns: [Square; Color::N], + pub tiles: BitBoard, + /// checksum stores the semi-unique [struct@Hash] of the current Position. + pub checksum: Hash, + /// side_to_move stores the piece whose turn to move it currently is. + pub side_to_move: Color, + pub ply_count: u16, +} + +impl PositionType for Position { + type BitBoard = BitBoard; + type ColoredPiece = ColoredPiece; + type Move = Move; + + fn insert(&mut self, sq: Square, piece: ColoredPiece) { + match piece.piece() { + Piece::Pawn => self.set_pawn(piece.color(), sq), + Piece::Tile => self.tiles.insert(sq), + } + } + + fn remove(&mut self, sq: Square) -> Option { + if self.pawn(Color::White) == sq { + Some(ColoredPiece::WhitePawn) + } else if self.pawn(Color::Black) == sq { + Some(ColoredPiece::BlackPawn) + } else if self.tiles.contains(sq) { + self.tiles ^= BitBoard::from(sq); + Some(ColoredPiece::Tile) + } else { + None + } + } + + fn at(&self, sq: Square) -> Option { + ColoredPiece::iter() + .find(|piece| self.colored_piece_bb(*piece).contains(sq)) + } + + fn piece_bb(&self, piece: Piece) -> BitBoard { + match piece { + Piece::Pawn => BitBoard::from(self.pawns[0]) | self.pawns[1], + Piece::Tile => self.tiles, + } + } + + fn color_bb(&self, color: Color) -> BitBoard { + BitBoard::from(self.pawn(color)) + } + + fn colored_piece_bb(&self, piece: ColoredPiece) -> BitBoard { + match piece.piece() { + Piece::Pawn => BitBoard::from(self.pawn(piece.color())), + Piece::Tile => self.tiles, + } + } + + fn hash(&self) -> Hash { + self.checksum + } + + fn is_game_over(&self) -> bool { + self.count_moves::() == 0 + } + + fn winner(&self) -> Option { + if self.is_game_over() { + Some(!self.side_to_move) + } else { + None + } + } + + fn after_move(&self, m: Move) -> Position { + let stm = self.side_to_move; + + let mut pawns = self.pawns; + + // Move our pawn to the new square. + pawns[stm as usize] = m.pawn(); + + // Remove the selected tile from the board. + let tiles = self.colored_piece_bb(ColoredPiece::Tile) + ^ BitBoard::from(m.tile()); + + // Update the position checksum. + let checksum = if UPDATE_HASH { + Self::get_hash(pawns, tiles, !stm) + } else { + Default::default() + }; + + Position { + pawns, + tiles, + checksum, + side_to_move: !stm, + ply_count: self.ply_count + 1, + } + } + + fn generate_moves_into< + const ALLOW_ILLEGAL: bool, + const QUIET: bool, + const NOISY: bool, + T: MoveStore, + >( + &self, + movelist: &mut T, + ) { + let stm = self.color_bb(self.side_to_move); + let xtm = self.color_bb(!self.side_to_move); + + let tiles = self.colored_piece_bb(ColoredPiece::Tile); + + // Pieces can only move to unoccupied Squares. + let allowed = tiles - xtm; + + for target in BitBoard::singles(stm) & allowed { + for tile in allowed ^ BitBoard::from(target) { + movelist.push(Move::new(target, tile)); + } + } + } + + fn count_moves(&self) -> usize { + let stm = self.color_bb(self.side_to_move); + let xtm = self.color_bb(!self.side_to_move); + + let tiles = self.colored_piece_bb(ColoredPiece::Tile); + + // Pieces can only move to unoccupied Squares. + let allowed = tiles - xtm; + + (BitBoard::singles(stm) & allowed).count() * (allowed.count() - 1) + } +} + +impl Position { + fn pawn(&self, color: Color) -> Square { + self.pawns[color as usize] + } + + fn set_pawn(&mut self, color: Color, square: Square) { + self.pawns[color as usize] = square + } + + fn get_hash( + pawns: [Square; Color::N], + tiles: BitBoard, + stm: Color, + ) -> Hash { + let a = pawns[0] as u64 * Square::N as u64 + pawns[1] as u64; + let b = tiles.into(); + + // Currently, an 2^-63-almost delta universal hash function, based on + // https://eprint.iacr.org/2011/116.pdf by Long Hoang Nguyen and Andrew + // William Roscoe is used to create the Hash. This may change in the future. + + // 3 64-bit integer constants used in the hash function. + const X: u64 = 6364136223846793005; + const Y: u64 = 1442695040888963407; + const Z: u64 = 2305843009213693951; + + // xa + yb + floor(ya/2^64) + floor(zb/2^64) + // floor(pq/2^64) is essentially getting the top 64 bits of p*q. + let part_1 = X.wrapping_mul(a); // xa + let part_2 = Y.wrapping_mul(b); // yb + let part_3 = (Y as u128 * a as u128) >> 64; // floor(ya/2^64) = ya >> 64 + let part_4 = (Z as u128 * b as u128) >> 64; // floor(zb/2^64) = zb >> 64 + + // add the parts together and return the resultant hash. + let hash = part_1 + .wrapping_add(part_2) + .wrapping_add(part_3 as u64) + .wrapping_add(part_4 as u64); + + // The Hash is bitwise complemented if the given side to move is Black. + // Therefore, if two Positions only differ in side to move, + // `a.Hash == !b.Hash`. + if stm == Color::Black { + Hash::new(!hash) + } else { + Hash::new(hash) + } + } +} + +/// PositionParseErr represents an error encountered while parsing +/// the given FEN position field into a valid Position. +#[derive(Error, Debug)] +pub enum PositionParseError { + #[error("expected 3 fields, found {0}")] + TooManyFields(usize), + + #[error("parsing piece placement: {0}")] + BadPiecePlacement(#[from] PiecePlacementParseError), + + #[error("parsing side to move: {0}")] + BadSideToMove(#[from] TypeParseError), + #[error("parsing full move count: {0}")] + BadFullMoveCount(#[from] ParseIntError), +} + +// FromStr implements parsing of the position field in a FEN. +impl FromStr for Position { + type Err = PositionParseError; + + fn from_str(s: &str) -> Result { + let parts = s.split(' ').collect::>(); + + if parts.len() != 3 { + return Err(PositionParseError::TooManyFields(parts.len())); + } + + let pos = parts[0]; + let stm = parts[1]; + let fmc = parts[2]; + + let mut position = Position { + pawns: [Square::A1, Square::A1], + tiles: BitBoard::EMPTY, + checksum: Default::default(), + side_to_move: Color::Black, + ply_count: 0, + }; + + interface::parse_piece_placement(&mut position, pos)?; + + position.side_to_move = Color::from_str(stm)?; + position.ply_count = fmc.parse::()? * 2 - 1; + if position.side_to_move == Color::White { + position.ply_count -= 1; + } + + // Calculate the Hash value for the Position. + position.checksum = Self::get_hash( + position.pawns, + position.colored_piece_bb(ColoredPiece::Tile), + position.side_to_move, + ); + + Ok(position) + } +} + +// Display implements displaying a Position using ASCII art. +impl fmt::Display for Position { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let board = self; + let mut string_rep = String::from(" "); + + for rank in Rank::iter().rev() { + for file in File::iter() { + let square = Square::new(file, rank); + let square_str = match board.at(square) { + Some(piece) => format!("{} ", piece), + None => ". ".to_string(), + }; + string_rep += &square_str; + } + + // Append the rank marker. + string_rep += &format!(" {} \n ", rank); + } + + // Append the file markers. + string_rep += "a b c d e f g h\n"; + + writeln!(f, "{}", string_rep).unwrap(); + writeln!(f, "Side To Move: {}", self.side_to_move) + } +} diff --git a/games/src/isolation/square.rs b/games/src/isolation/square.rs new file mode 100644 index 0000000..001a4a3 --- /dev/null +++ b/games/src/isolation/square.rs @@ -0,0 +1,49 @@ +// Copyright © 2024 Rak Laptudirm +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::fmt; +use std::str::FromStr; + +use crate::interface::{representable_type, RepresentableType, SquareType}; + +representable_type!( + /// Square represents all the squares present on an Isolation Board. + /// The index of each Square is equal to `rank-index * 8 + file-index`. + enum Square: u8 { + A1 "a1", B1 "b1", C1 "c1", D1 "d1", E1 "e1", F1 "f1", G1 "G1", H1 "H1", + A2 "a2", B2 "b2", C2 "c2", D2 "d2", E2 "e2", F2 "f2", G2 "G2", H2 "H2", + A3 "a3", B3 "b3", C3 "c3", D3 "d3", E3 "e3", F3 "f3", G3 "G3", H3 "H3", + A4 "a4", B4 "b4", C4 "c4", D4 "d4", E4 "e4", F4 "f4", G4 "G4", H4 "H4", + A5 "a5", B5 "b5", C5 "c5", D5 "d5", E5 "e5", F5 "f5", G5 "G5", H5 "H5", + A6 "a6", B6 "b6", C6 "c6", D6 "d6", E6 "e6", F6 "f6", G6 "G6", H6 "H6", + } +); + +impl SquareType for Square { + type File = File; + type Rank = Rank; +} + +representable_type!( + /// File represents a file on the Isolation Board. Each vertical column of + /// Squares on an Isolation Board is known as a File. + enum File: u8 { A "a", B "b", C "c", D "d", E "e", F "f", G "g", H "h", } +); + +representable_type!( + /// Rank represents a rank on the Isolation Board. Each horizontal row of Squares + /// on an Isolation Board is known as a Rank. + enum Rank: u8 { + First "1", Second "2", Third "3", Fourth "4", Fifth "5", Sixth "6", + } +); diff --git a/games/src/isolation/tests/mod.rs b/games/src/isolation/tests/mod.rs new file mode 100644 index 0000000..2300a9d --- /dev/null +++ b/games/src/isolation/tests/mod.rs @@ -0,0 +1,19 @@ +use super::Position; +use crate::perft; +use std::str::FromStr; + +macro_rules! perft_test { + ($name:ident $pos:literal $depth:literal $nodes:literal) => { + #[test] + fn $name() { + let position = Position::from_str($pos).unwrap(); + assert_eq!(perft::(position, $depth), $nodes) + } + }; +} + +perft_test!(startpos_1 "---p--/------/------/------/------/------/------/--P--- w 1" 1 225); +perft_test!(startpos_2 "---p--/------/------/------/------/------/------/--P--- w 1" 2 47300); +perft_test!(startpos_3 "---p--/------/------/------/------/------/------/--P--- w 1" 3 10998540); +perft_test!(startpos_4 "---p--/------/------/------/------/------/------/--P--- w 1" 4 2434427562); +perft_test!(startpos_5 "---p--/------/------/------/------/------/------/--P--- w 1" 5 561436288000); diff --git a/games/src/lib.rs b/games/src/lib.rs index d063b3f..1c9d7f0 100644 --- a/games/src/lib.rs +++ b/games/src/lib.rs @@ -1,5 +1,6 @@ pub mod ataxx; pub mod interface; +pub mod isolation; use interface::PositionType;