//! All things to do with the Othello game board. This module contains types, //! traits, and functions for working with the board. use std::fmt::{Debug, Display}; use crate::{ board::view::{Overlay, View}, game::Team, }; pub mod view; pub type Board = u64; // Directions: // 7 8 9 // -1 0 1 // -9 -8 -7 // // 0 is where "we" are. The other numbers represent the index shifts to access // both orthogonal and diagonal adjecent squares. // // Note that negative numbers indicate a shift to the right while positive ones // indicate a left shift. // // When we want to shift from a higher place to a lower place (when looking // straight to the right or up in any direction for example), we aren't really // moving toward _it_. It's more so that we're moving that toward us. So a // digit further along in the structure (positive numbers) will require // shifting to the left to bring it to match our current focus. Thus: // Positive => SHL // Negative => SHR // simply negating the A file const NOT_A_FILE: Board = 0x7F7F7F7F7F7F7F7F; // simply negating the H file const NOT_H_FILE: Board = 0xFEFEFEFEFEFEFEFE; const SHIFT_MASK_COMBOS: [(i8, Board); 8] = [ (9, NOT_A_FILE), // 9 (up right) (8, Board::MAX), // 8 (up) (7, NOT_H_FILE), // 7 (up left) (1, NOT_A_FILE), // 1 (right) (-1, NOT_H_FILE), // -1 (left) (-7, NOT_A_FILE), // -7 (down right) (-8, Board::MAX), // -8 (down) (-9, NOT_H_FILE), // -9 (down left) ]; /// Represents the board state using two integers. This allows us to reduce the /// memory footprint by 75% when compared against using a byte[8][8] (64 /// bytes). While digits in these boards do not have _place value_ per se, it /// still may be helpful to think of the structure as being Big Endian and /// by that I mean to say that h8 is the leftmost bit and a1 is the rightmost. /// /// The values are to be mapped as follows where each number indicates the bit /// in the integer that corresponds to the position in the board: /// /// ```text /// a: b: c: d: e: f: g: h: /// 8: 56 57 58 59 60 61 62 63 :8 /// 7: 48 49 50 51 52 53 54 55 :7 /// 6: 40 41 42 43 44 45 46 47 :6 /// 5: 32 33 34 35 36 37 38 39 :5 /// 4: 24 25 26 27 28 29 30 31 :4 /// 3: 16 17 18 19 20 21 22 23 :3 /// 2: 08 09 10 11 12 13 14 15 :2 /// 1: 00 01 02 03 04 05 06 07 :1 /// a: b: c: d: e: f: g: h: /// /// = /// /// bits: 00 01 ... 07 08 09 ... 15 ... 56 57 ... 63 /// board: a1 b1 ... h1 a2 b2 ... h2 ... a8 b8 ... h8 /// ``` /// /// Note that I use the traditional nomenclature of ranks and files which /// correspond to rows and columns respectively. /// /// Like I note later on, there is some variation on how precisely ranks /// and files are to be rendered or oriented due to the symmetrical nature /// of Othello, but I stick to rigid Chess-esque conventions for the sake of my /// sanity. #[derive(Copy, Clone, PartialEq)] pub struct BitBoard { /// Contains all boards for game. In this case, there are only two. /// The first is black. The second is white. pub boards: [Board; 2], } impl Default for BitBoard { fn default() -> Self { use squares::*; // Create board in standard starting structure Self { boards: [D5 | E4, E5 | D4], } } } impl Debug for BitBoard { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BitBoard") .field("black", &format!("0x{:X}", &self.boards[0])) .field("white", &format!("0x{:X}", &self.boards[1])) .finish() } } impl Display for BitBoard { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Render the board in a chess style. There seems to be differing opinions // on how ranks and files should be displayed in Othello (mirroring vs rotation), // so I just print the board in the Chess style since it makes more sense to me. // // For the sake of testing, it's also super convenient to not have the board // flipping and rotating in the output. It makes it way easier to compare two // boards side by side. // for each rank _r_ in reverse since our structure is Big Endian and we want // to print the 8 rank first (chess style in white perspective). for r in (0..8).rev() { let start_i = r * 8; write!(f, "{}: ", r + 1).unwrap(); for i in start_i..(start_i + 8) { // rshift to cut off bits on rhs, then AND on 1 to get just the last bit. write!( f, "{}", if (self.boards[0] >> i) & 1 == 1 { 'B' } else if (self.boards[1] >> i) & 1 == 1 { 'W' } else { '-' } ) .unwrap(); } if r != 0 { writeln!(f).unwrap(); } } write!(f, "\n abcdefgh").unwrap(); Ok(()) } } fn shift_in_direction(magnitude: i8, value: Board) -> Board { if magnitude >= 0 { let (result, _) = value.overflowing_shl(magnitude.unsigned_abs() as u32); result } else { let (result, _) = value.overflowing_shr(magnitude.unsigned_abs() as u32); result } } impl BitBoard { /// Create a new BitBoard from provided team boards. pub fn new(black: Board, white: Board) -> Self { Self { boards: [black, white], } } /// Convert from a JON (Jack Othello Notation) string into a usable board. /// Format: /// /// | Symbol or Pattern | Meaning | /// | ------------------ | --------------------------------- | /// | `/` | _next line_ | /// | `[0-9]` | _jump squares_ | /// | `[wWbB]` | _white or black disc at position_ | /// /// Example: `///3bw/3wb///` is the JON for the starting position. /// It means three empty ranks, three empty spaces followed by a black disc, /// a white disc, next rank, three empty spaces followed by a white disc, /// a black disc, and then three empty ranks. It also technically could be /// truncated after the second `b` as `///3bw/3wb` since trailing rank is /// optional. pub fn from_jon(fen: &str) -> Result { let mut boards: [Board; 2] = [0, 0]; let mut line: u8 = 7; let mut rank_idx: u8 = 0; let mut rank_jump: u8 = 0; for c in fen.chars() { match c.to_ascii_lowercase() { '/' => { line = line.checked_sub(1).ok_or("Malformed JON: Too many ranks")?; rank_idx = 0; rank_jump = 0; } 'b' => { boards[0] += 1 << (line * 8 + rank_idx + rank_jump); rank_idx += rank_jump + 1; rank_jump = 0; } 'w' => { boards[1] += 1 << (line * 8 + rank_idx + rank_jump); rank_idx += rank_jump + 1; rank_jump = 0; } '0'..='8' => { rank_jump = c .to_digit(10u32) .ok_or("JON Parsing error: Failed to parse integer from character")? as u8; } c => { return Err(format!( "Malformed JON: Unexpected character encountered ({})", c )); } } } Ok(Self { boards }) } pub fn player_at(&self, rank: u8, file: u8) -> Option { if self.boards[0] & (1 << (rank * 8 + file)) != 0 { Some(Team::Black) } else if self.boards[1] & (1 << (rank * 8 + file)) != 0 { Some(Team::White) } else { None } } /// Compute board with valid moves marked using only bitwise operations. /// This has constant time complexity and is unbelievably fast. pub fn available(&self, current_team: Team) -> Board { // bitwise OR gives spots with either white OR black discs // bitwise NEG gives the spots with neither white nor black discs let empties = !(self.boards[0] | self.boards[1]); let mut moves = 0; let (team, opponent) = ( self.boards[current_team as usize], self.boards[current_team.next() as usize], ); // Instead of looping through a structure and checking adjacent // squares, we are able to rather easily aggregate these operations // into a handful of bitwise operations. So for example, let's // consider the starting board (illustrations omitting some // unimportant ranks): // // Game Black White // -------- -------- -------- // ---WB--- = ----B--- + ---W---- // ---BW--- ---B---- ----W--- // -------- -------- -------- // // It's black's turn. Let's first consider how we might find any // white pieces that are adjacent to black pieces. Let's just check // for white pieces that are to the _right_ of black ones. We can do // this by shifting all black digits to the left once (see info about // shifts and directions earlier in module). // // Black << 1 = // -------- // -----B-- // ----B--- // -------- // // We can then find where this shifted board intersects with white's // board using bitwise AND. // // Black<<1 & White = Right-adjacents (A) // // -------- -------- -------- // -----B-- & ---W---- = -------- // ----B--- ----W--- ----A--- // -------- -------- -------- // // We now have all the white matrices to the right of black matrices. // We can continue doing this in different directions using different // values to shift with (they are the same every time), using masks // here and there to avoid problems with the outer files, and then // bitwise OR-ing these together to get a matrix containing all // adjacencies. // // Certain shifts are associated with particular masks by matching // indices. So for example, when we're looking to the right, we want to // ignore the _a_ file. This is because there's no way of placing a disc // in an orientation such that a disc in the _a_ file is flanked on its // right side. We do the same for when looking left with the _h_ file. for (shift, mask) in SHIFT_MASK_COMBOS { // begin performing masks based on where opponent positions are // since we can flank multiple pieces in a single direction, we // apply this logic seven times as this would account for the // longest possible flank. let mut sub_move = shift_in_direction(shift, team & mask) & opponent; sub_move |= shift_in_direction(shift, sub_move & mask) & opponent; sub_move |= shift_in_direction(shift, sub_move & mask) & opponent; sub_move |= shift_in_direction(shift, sub_move & mask) & opponent; sub_move |= shift_in_direction(shift, sub_move & mask) & opponent; sub_move |= shift_in_direction(shift, sub_move & mask) & opponent; sub_move = shift_in_direction(shift, sub_move & mask) & empties; moves |= sub_move; } moves } pub fn render(&self, view: impl Into, overlays: Vec) -> String { let view: View = view.into(); let mut result = String::new(); let raw_r_range = 0..8; let r_range = match view { View::RankAsc => Box::new(raw_r_range) as Box>, View::RankDesc => Box::new(raw_r_range.rev()) as Box>, }; for (r_idx, r) in r_range.enumerate() { let start_i = r * 8; result.push_str(&format!("{}: ", r + 1)); for i in start_i..(start_i + 8) { // rshift to cut off bits on rhs, then AND on 1 to get just the last bit. result.push_str(&format!( "\x1b[42m{} \x1b[0m", if (self.boards[0] >> i) & 1 == 1 { String::from("\x1b[30m●") } else if (self.boards[1] >> i) & 1 == 1 { String::from("\x1b[97m●") } else { // if our overlay provides a character for the square, use it. // otherwise use the default char '-' overlays .iter() .find(|Overlay(board, _)| (board >> i) & 1 == 1) .map(|Overlay(_, c)| *c) .unwrap_or("░") .to_owned() } )); } if r_idx != 7 { result.push('\n'); } } result.push_str("\n a b c d e f g h"); result } /// Apply play to a board and compute effected reversals pub fn play(&mut self, current_team: Team, play: Board) { // bitwise OR gives spots with either white OR black discs // bitwise NEG gives the spots with neither white nor black discs let mut flips = 0; let current_team_idx = current_team as usize; let (team, opponent) = ( self.boards[current_team_idx], self.boards[current_team.next() as usize], ); // For each (shift, mask) pair, check whether there's an opponent disc in // the direction of the shift. We mask with `mask` before shifting to avoid // wrapping horizontally around the edges of the board. We do this six times // since the maximum amount of flipped discs is six. After we're finished // shifting, we shift once more with the same rules and then check if we // intersect with a disc of our own by comparing the bitwise conjunction to // zero. If it's greater than zero, then we've definitely detected a() flip(s) // in that direction. We then add these to our running summation of flips. for (shift, mask) in SHIFT_MASK_COMBOS { let mut flip = shift_in_direction(shift, play & mask) & opponent; flip |= shift_in_direction(shift, flip & mask) & opponent; flip |= shift_in_direction(shift, flip & mask) & opponent; flip |= shift_in_direction(shift, flip & mask) & opponent; flip |= shift_in_direction(shift, flip & mask) & opponent; flip |= shift_in_direction(shift, flip & mask) & opponent; if shift_in_direction(shift, flip & mask) & team > 0 { flips |= flip; } } self.boards[current_team_idx] += flips; self.boards[current_team.next() as usize] ^= flips; self.boards[current_team_idx] += play; } /// Compute the score (B, W) by counting the excited bits in each board. pub fn score(&self) -> (u32, u32) { // `u64::count_ones()` is implemented `unsafe` and makes use of primitives in the // low-level implementation of the language itself for maximum performance. Better // to use this than implement myself. (self.boards[0].count_ones(), self.boards[1].count_ones()) } } pub mod squares { /// Just allows us to easily define squares in terms of their shift macro_rules! bitboard_consts { ($($name:ident = $digit:expr), * $(,)?) => { $(#[allow(dead_code)] pub const $name: super::Board = 1 << $digit;)* }; } bitboard_consts!( A1 = 0, B1 = 1, C1 = 2, D1 = 3, E1 = 4, F1 = 5, G1 = 6, H1 = 7, A2 = 8, B2 = 9, C2 = 10, D2 = 11, E2 = 12, F2 = 13, G2 = 14, H2 = 15, A3 = 16, B3 = 17, C3 = 18, D3 = 19, E3 = 20, F3 = 21, G3 = 22, H3 = 23, A4 = 24, B4 = 25, C4 = 26, D4 = 27, E4 = 28, F4 = 29, G4 = 30, H4 = 31, A5 = 32, B5 = 33, C5 = 34, D5 = 35, E5 = 36, F5 = 37, G5 = 38, H5 = 39, A6 = 40, B6 = 41, C6 = 42, D6 = 43, E6 = 44, F6 = 45, G6 = 46, H6 = 47, A7 = 48, B7 = 49, C7 = 50, D7 = 51, E7 = 52, F7 = 53, G7 = 54, H7 = 55, A8 = 56, B8 = 57, C8 = 58, D8 = 59, E8 = 60, F8 = 61, G8 = 62, H8 = 63, ); } #[cfg(test)] mod tests { use super::squares::*; use super::*; #[test] fn available_works() { let bb = BitBoard::default(); assert_eq!(bb.available(Team::Black), E6 | F5 | C4 | D3); } #[test] fn display_works() { let bb = BitBoard::default(); let expected_output = r"8: -------- 7: -------- 6: -------- 5: ---BW--- 4: ---WB--- 3: -------- 2: -------- 1: -------- abcdefgh"; assert_eq!(format!("{}", bb), expected_output); } #[test] fn jon_works() { let bb = BitBoard::from_jon("///3bw/3wb///").expect("Starting board should be valid"); assert_eq!(bb.boards, BitBoard::default().boards); // trailing slashes are optional let bb = BitBoard::from_jon("///3bw/3wb") .expect("Starting board without trailing slashes should be valid"); assert_eq!(bb.boards, BitBoard::default().boards); // test with too many slashes assert!(BitBoard::from_jon("////////").is_err()); // test with unexpected char assert!(BitBoard::from_jon("//c/////").is_err()); } #[test] fn test_available_starting_position_black() { let bb = BitBoard::default(); assert_eq!(bb.available(Team::Black), E6 | F5 | C4 | D3); } #[test] fn test_available_starting_position_white() { let bb = BitBoard::default(); assert_eq!(bb.available(Team::White), D6 | E3 | C5 | F4); } #[test] fn test_available_empty_board() { let bb = BitBoard::from_jon("///////").expect("Valid board"); assert_eq!(bb.available(Team::Black), 0); assert_eq!(bb.available(Team::White), 0); } #[test] fn test_available_single_piece_no_moves() { // Only white at d4, no black pieces let bb = BitBoard::from_jon("///3w///").expect("Valid board"); assert_eq!(bb.available(Team::Black), 0); assert_eq!(bb.available(Team::White), 0); } #[test] fn test_available_horizontal_line() { // Black at a4, white at b4-f4, empty g4, black at h4 let bb = BitBoard::from_jon("////bwwwww1b///").expect("Valid board"); // Black can play at g4 to capture white pieces assert_eq!(bb.available(Team::Black), G4); } #[test] fn test_available_vertical_capture() { // Black at a8, white at a7, a6, a5, empty a4, black at a3 let bb = BitBoard::from_jon("b/w/w/w//b//").expect("Valid board"); // Black can play at a4 to capture white pieces between a3 and a8 assert_eq!(bb.available(Team::Black), A4); } #[test] fn test_available_diagonal_capture_tr() { // Black at a1, white at b2, c3, empty d4 let bb = BitBoard::from_jon("/////2w/1w/b").expect("Valid board"); // Black can play at d4 to reverse on top-right diagonal assert_eq!(bb.available(Team::Black), D4); } #[test] fn test_available_diagonal_capture_tl() { // Black at h1, white at g2, f3, empty e4 let bb = BitBoard::from_jon("/////5w/6w/7b").expect("Valid board"); // Black can play at e4 to reverse on top-left-diagonal assert_eq!(bb.available(Team::Black), E4); } #[test] fn test_available_diagonal_capture_br() { // Black at a1, white at b2, c3, empty d4 let bb = BitBoard::from_jon("w/1b/2b").expect("Valid board"); // White can play at d5 to reverse on bottom-right diagonal assert_eq!(bb.available(Team::White), D5); } #[test] fn test_available_diagonal_capture_bl() { // Black at h1, white at g2, f3, empty e4 let bb = BitBoard::from_jon("7w/6b/5b").expect("Valid board"); // White can play at e5 to reverse on bottom-left diagonal assert_eq!(bb.available(Team::White), E5); } #[test] fn test_available_corner_a1() { // Setup where black can capture into corner a1 // White at b1, black at c1, empty a1 let bb = BitBoard::from_jon("///////wb").expect("Valid board"); // Black can play at a1 to capture b1 assert_eq!(bb.available(Team::Black), 0); assert_eq!(bb.available(Team::White), C1); } #[test] fn test_available_corner_h8() { // Setup where black can capture into corner h8 // Black at f8, white at g8, empty h8 let bb = BitBoard::from_jon("5bw//////").expect("Valid board"); // Black can play at h8 to capture g8 assert_eq!(bb.available(Team::Black), H8); } #[test] fn test_available_multiple_directions() { // White at d4, black at d3, d5, c4, e4 (surrounding white) let bb = BitBoard::from_jon("///3b/2bwb/3b//").expect("Valid board"); // White can play at d2, d6, b4, or f4 to capture black assert_eq!(bb.available(Team::White), D2 | D6 | B4 | F4); } #[test] fn test_available_no_moves_surrounded() { // Black piece at b7 completely surrounded by white let bb = BitBoard::from_jon("www/wbw/www////").expect("Valid board"); println!("dis:\n{}", bb); // Black should have three moves in this position: d7, b5, and d5 assert_eq!(bb.available(Team::Black), D7 | B5 | D5); } #[test] fn test_available_long_capture() { // Black at a4, white at b4-f4, empty g4, black at h4 let bb = BitBoard::from_jon("////bwwwww1b///").expect("Valid board"); println!("dis:\n{}", bb); // Black can play at g4 to capture entire row of white pieces assert_eq!(bb.available(Team::Black), G4); } #[test] fn test_available_edge_moves() { // Black at a1, white at b1, c1, empty d1 let bb = BitBoard::from_jon("///////bww").expect("Valid board"); // Black can play at d1 to capture b1 and c1 assert_eq!(bb.available(Team::Black), D1); } #[test] fn test_available_after_first_move() { // After black plays d3 from starting position let bb = BitBoard::from_jon("///3bw/2bbw/3b//").expect("Valid board"); // White should be able to play at several positions let available = bb.available(Team::White); // White can at minimum play c3, e3, c5 assert_ne!(available, 0); assert_eq!(available & C3, C3); } #[test] fn test_available_complex_midgame() { // A complex position with multiple pieces let bb = BitBoard::from_jon("//1b2w/1bwwww/1bwbww/1bwwww/1b3/").expect("Valid board"); let available = bb.available(Team::Black); // Black should have at least some moves available assert_ne!(available, 0); // In fact, it should have the following options available: assert_eq!(available, F7 | D6 | F6 | G4 | F2 | E2 | D2 | G5 | G3); } #[test] fn test_available_no_opponent_pieces() { // Board with only white pieces (two rows) let bb = BitBoard::from_jon("wwwwwwww/wwwwwwww/////").expect("Valid board"); // Black has no pieces, so no moves assert_eq!(bb.available(Team::Black), 0); assert_eq!(bb.available(Team::White), 0); } #[test] fn test_available_all_four_corners() { // Test capture opportunities in all four corners // a1 corner: white at b1, black at c1 let bb = BitBoard::from_jon("///////wb").expect("Valid board"); assert_eq!(bb.available(Team::White), C1); // h1 corner: black at f1, white at g1 let bb = BitBoard::from_jon("///////6bw").expect("Valid board"); assert_eq!(bb.available(Team::White), F1); // a8 corner: white at b8, black at c8 let bb = BitBoard::from_jon("wb").expect("Valid board"); assert_eq!(bb.available(Team::White), C8); // h8 corner: white at b8, black at c8 let bb = BitBoard::from_jon("6wb").expect("Valid board"); assert_eq!(bb.available(Team::Black), F8); } #[allow(dead_code)] fn render_dbg(board: BitBoard) -> BitBoard { println!("dbg:\n{}", board.render(View::RankAsc, vec![])); board } #[test] fn play_works() { let mut bb = BitBoard::default(); assert_eq!(bb.score(), (2, 2)); bb.play(Team::Black, E6); assert_eq!(bb, BitBoard::new(D5 | E6 | E5 | E4, D4)); assert_eq!(bb.score(), (4, 1)); bb.play(Team::White, F4); assert_eq!(bb, BitBoard::new(D5 | E6 | E5, D4 | E4 | F4)); assert_eq!(bb.score(), (3, 3)); bb.play(Team::Black, G3); assert_eq!(bb, BitBoard::new(D5 | E6 | E5 | G3 | F4, D4 | E4)); assert_eq!(bb.score(), (5, 2)); bb.play(Team::White, E7); assert_eq!(bb, BitBoard::new(D5 | G3 | F4, D4 | E4 | E5 | E6 | E7)); assert_eq!(bb.score(), (3, 5)); } }