Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: add the game isolation
Browse files Browse the repository at this point in the history
raklaptudirm committed Dec 12, 2024
1 parent 48cd33d commit 5b3e6bb
Showing 8 changed files with 706 additions and 1 deletion.
2 changes: 1 addition & 1 deletion games/src/interface/move.rs
Original file line number Diff line number Diff line change
@@ -42,7 +42,7 @@ pub trait MoveStore<M>: 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<M> = ArrayVec<M, 256>;
pub type MoveList<M> = ArrayVec<M, 500>;

// MoveStore implementation for MoveList.
impl<M> MoveStore<M> for MoveList<M> {
44 changes: 44 additions & 0 deletions games/src/isolation/bitboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright © 2024 Rak Laptudirm <[email protected]>
//
// 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(0x1ffffffffffff);

// BitBoards containing the squares of the first file and the first rank.
FirstFile = Self(0x0040810204081);
FirstRank = Self(0x000000000007f);
}
}

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
}
}
15 changes: 15 additions & 0 deletions games/src/isolation/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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;
208 changes: 208 additions & 0 deletions games/src/isolation/move.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// Copyright © 2024 Rak Laptudirm <[email protected]>
//
// 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 Ataxx 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<u16> for Move {
fn from(value: u16) -> Self {
Move(value)
}
}

impl From<Move> 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;

/// NULL Move represents an invalid move.
pub const NULL: Move = Move(1 << 15);

/// new_single returns a new singular Move, where a piece is cloned to its
/// target Square. For a singular Move, [`Move::source`] and [`Move::target`]
/// are equal since the source Square is irrelevant to the Move.
/// ```
/// use tetka_games::ataxx::*;
///
/// let mov = Move::new_single(Square::A1);
///
/// assert_eq!(mov.source(), mov.target());
/// assert_eq!(mov.target(), Square::A1);
/// ```
#[inline(always)]
pub fn new_single(square: Square) -> Move {
Move::new(square, square)
}

/// new returns a new jump Move from the given source Square to the given
/// target Square. These Squares can be recovered with the [`Move::source`] and
/// [`Move::target`] methods respectively.
/// ```
/// use tetka_games::ataxx::*;
///
/// let mov = Move::new(Square::A1, Square::A3);
///
/// assert_eq!(mov.source(), Square::A1);
/// assert_eq!(mov.target(), Square::A3);
/// ```
#[inline(always)]
#[rustfmt::skip]
pub fn new(source: Square, target: Square) -> Move {
Move(
(source as u16) << Move::PAWN_OFFSET |
(target as u16) << Move::TILE_OFFSET
)
}

/// Source returns the source Square of the moving piece. This is equal to the
/// target Square if the given Move is of singular type.
/// ```
/// use tetka_games::ataxx::*;
///
/// let mov = Move::new(Square::A1, Square::A3);
///
/// assert_eq!(mov.source(), Square::A1);
/// ```
pub fn pawn(self) -> Square {
unsafe {
Square::unsafe_from((self.0 >> Move::PAWN_OFFSET) & Move::PAWN_MASK)
}
}

/// Target returns the target Square of the moving piece.
/// ```
/// use tetka_games::ataxx::*;
///
/// let mov = Move::new(Square::A1, Square::A3);
///
/// assert_eq!(mov.target(), 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 source 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 formats supported are '0000' for a [Move::PASS], `<target>` for a
/// singular Move, and `<source><target>` for a jump Move. For how `<source>`
/// and `<target>` 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::ataxx::*;
/// use std::str::FromStr;
///
/// let pass = Move::PASS;
/// let sing = Move::new_single(Square::A1);
/// let jump = Move::new(Square::A1, Square::A3);
///
/// assert_eq!(Move::from_str(&pass.to_string()).unwrap(), pass);
/// assert_eq!(Move::from_str(&sing.to_string()).unwrap(), sing);
/// assert_eq!(Move::from_str(&jump.to_string()).unwrap(), jump);
/// ```
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() != 2 && s.len() != 4 {
return Err(MoveParseError::BadLength(s.len()));
}

let source = &s[..2];
let source = Square::from_str(source)?;

if s.len() < 4 {
return Ok(Move::new_single(source));
}

let target = &s[2..];
let target = Square::from_str(target)?;

Ok(Move::new(source, target))
}
}

impl fmt::Display for Move {
/// Display formats the given Move in a human-readable manner. The format used
/// for displaying jump moves is `<source><target>`, while a singular Move is
/// formatted as `<target>`. For the formatting of `<source>` and `<target>`,
/// refer to `Square::Display`. [`Move::NULL`] is formatted as `null`, while
/// [`Move::PASS`] is formatted as `0000`.
/// ```
/// use tetka_games::ataxx::*;
///
/// let null = Move::NULL;
/// let pass = Move::PASS;
/// let sing = Move::new_single(Square::A1);
/// let jump = Move::new(Square::A1, Square::A3);
///
/// assert_eq!(null.to_string(), "null");
/// assert_eq!(pass.to_string(), "0000");
/// assert_eq!(sing.to_string(), "a1");
/// 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)
}
}
66 changes: 66 additions & 0 deletions games/src/isolation/piece.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright © 2024 Rak Laptudirm <[email protected]>
//
// 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"),
}
}
}
320 changes: 320 additions & 0 deletions games/src/isolation/position.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
// Copyright © 2024 Rak Laptudirm <rak@laptudirm.com>
//
// 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::cmp;
use std::fmt;
use std::num::ParseIntError;
use std::str::FromStr;

use strum::IntoEnumIterator;

use crate::interface;
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 Ataxx Board, the state of the an
/// ataxx 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 {
/// bitboards stores [BitBoard]s for the piece configuration of each piece.
pub bitboards: [BitBoard; ColoredPiece::N],
/// 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) {
self.bitboards[piece as usize].insert(sq);
}

fn remove(&mut self, sq: Square) -> Option<ColoredPiece> {
match self.at(sq) {
Some(piece) => {
self.bitboards[piece as usize].remove(sq);
Some(piece)
}
None => None,
}
}

fn at(&self, sq: Square) -> Option<ColoredPiece> {
ColoredPiece::iter()
.find(|piece| self.colored_piece_bb(*piece).contains(sq))
}

fn piece_bb(&self, piece: Piece) -> BitBoard {
self.bitboards[piece as usize]
}

fn color_bb(&self, color: Color) -> BitBoard {
self.bitboards[color as usize]
}

fn colored_piece_bb(&self, piece: ColoredPiece) -> BitBoard {
self.bitboards[piece as usize]
}

fn hash(&self) -> Hash {
self.checksum
}

fn is_game_over(&self) -> bool {
let black = self.colored_piece_bb(ColoredPiece::WhitePawn);
let white = self.colored_piece_bb(ColoredPiece::BlackPawn);
let block = self.colored_piece_bb(ColoredPiece::Tile);

white | black | block == BitBoard::UNIVERSE || // All squares occupied
white == BitBoard::EMPTY || black == BitBoard::EMPTY // No pieces left
}

fn winner(&self) -> Option<Color> {
let black = self.colored_piece_bb(ColoredPiece::WhitePawn);
let white = self.colored_piece_bb(ColoredPiece::BlackPawn);
let block = self.colored_piece_bb(ColoredPiece::Tile);

if black == BitBoard::EMPTY {
// Black lost all its pieces, White won.
return Some(Color::White);
} else if white == BitBoard::EMPTY {
// White lost all its pieces, Black won.
return Some(Color::Black);
}

debug_assert!(black | white | block == BitBoard::UNIVERSE);

// All the squares are occupied by pieces. Victory is decided by
// which Piece has the most number of pieces on the Board.

let black_n = black.len();
let white_n = white.len();

match black_n.cmp(&white_n) {
cmp::Ordering::Less => Some(Color::White),
cmp::Ordering::Greater => Some(Color::Black),
// Though there can't be an equal number of black and white pieces
// on an empty ataxx board, it is possible with an odd number of
// blocker pieces.
cmp::Ordering::Equal => None,
}
}

fn after_move<const UPDATE_HASH: bool>(&self, m: Move) -> Position {
let stm = self.side_to_move;

macro_rules! update_hash {
($e:expr) => {
if UPDATE_HASH {
$e
} else {
Default::default()
}
};
}

let xtm_pieces = self.color_bb(!stm);
let new_stm = BitBoard::from(m.pawn());
let new_tiles = self.colored_piece_bb(ColoredPiece::Tile)
^ BitBoard::from(m.tile());

let (white, black) = if stm == Color::White {
(new_stm, xtm_pieces)
} else {
(xtm_pieces, new_stm)
};

Position {
bitboards: [white, black, new_tiles],
checksum: update_hash!(Self::get_hash(black, white, !stm)),
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<Move>,
>(
&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<const QUIET: bool, const NOISY: bool>(&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 get_hash(black: BitBoard, white: BitBoard, stm: Color) -> Hash {
let a = black.into();
let b = white.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 half-move clock: {0}")]
BadHalfMoveClock(#[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<Self, Self::Err> {
let parts = s.split(' ').collect::<Vec<&str>>();

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 {
bitboards: [BitBoard::EMPTY; ColoredPiece::N],
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::<u16>()? * 2 - 1;
if position.side_to_move == Color::Black {
position.ply_count -= 1;
}

// Calculate the Hash value for the Position.
position.checksum = Self::get_hash(
position.colored_piece_bb(ColoredPiece::WhitePawn),
position.colored_piece_bb(ColoredPiece::BlackPawn),
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\n";

writeln!(f, "{}", string_rep).unwrap();
writeln!(f, "Side To Move: {}", self.side_to_move)
}
}
51 changes: 51 additions & 0 deletions games/src/isolation/square.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright © 2024 Rak Laptudirm <rak@laptudirm.com>
//
// 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 Ataxx 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",
A2 "a2", B2 "b2", C2 "c2", D2 "d2", E2 "e2", F2 "f2",
A3 "a3", B3 "b3", C3 "c3", D3 "d3", E3 "e3", F3 "f3",
A4 "a4", B4 "b4", C4 "c4", D4 "d4", E4 "e4", F4 "f4",
A5 "a5", B5 "b5", C5 "c5", D5 "d5", E5 "e5", F5 "f5",
A6 "a6", B6 "b6", C6 "c6", D6 "d6", E6 "e6", F6 "f6",
A7 "a7", B7 "b7", C7 "c7", D7 "d7", E7 "e7", F7 "f7",
A8 "a8", B8 "b8", C8 "c8", D8 "d8", E8 "e8", F8 "f8",
}
);

impl SquareType for Square {
type File = File;
type Rank = Rank;
}

representable_type!(
/// File represents a file on the Ataxx Board. Each vertical column of Squares
/// on an Ataxx Board is known as a File. There are 7 of them in total.
enum File: u8 { A "a", B "b", C "c", D "d", E "e", F "f", }
);

representable_type!(
/// Rank represents a rank on the Ataxx Board. Each horizontal row of Squares
/// on an Ataxx Board is known as a Rank. There are 7 of them in total.
enum Rank: u8 {
First "1", Second "2", Third "3", Fourth "4", Fifth "5", Sixth "6", Seventh "7", Eighth "8",
}
);
1 change: 1 addition & 0 deletions games/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod ataxx;
pub mod interface;
pub mod isolation;

use interface::PositionType;

0 comments on commit 5b3e6bb

Please sign in to comment.