othello/othello/src/board.rs
2025-10-30 00:15:03 -05:00

550 lines
18 KiB
Rust

//! 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::game::Team;
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
const DIRECTIONS: [i8; 8] = [9, 8, 7, 1, -1, -7, -8, -9];
// simply negating the A file
const NOT_A_FILE: Board = 0x7F7F7F7F7F7F7F7F;
// simply negating the H file
const NOT_H_FILE: Board = 0xFEFEFEFEFEFEFEFE;
const DIRECTION_MASKS: [Board; 8] = [
NOT_A_FILE, // 9 (up right)
Board::MAX, // 8 (up)
NOT_H_FILE, // 7 (up left)
NOT_A_FILE, // 1 (right)
NOT_H_FILE, // -1 (left)
NOT_A_FILE, // -7 (down right)
Board::MAX, // -8 (down)
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 Little Endian and
/// by that I mean to say that a0 is the leftmost bit and h8 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.
pub struct BitBoard {
/// Contains all boards for game. In this case, there are only two.
/// The first is black. The second is white.
boards: [Board; 2],
}
impl Default for BitBoard {
fn default() -> Self {
// Create board in standard starting structure
Self {
boards: [(1 << 36) + (1 << 27), (1 << 35) + (1 << 28)],
}
}
}
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 {
for r in (0..8).rev() {
let start_i = r * 8;
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 {
write!(f, "\n").unwrap();
}
}
Ok(())
}
}
fn shift_in_direction(magnitude: i8, value: Board) -> Board {
if magnitude >= 0 {
let (result, _) = value.overflowing_shl(magnitude.abs() as u32);
result
} else {
let (result, _) = value.overflowing_shr(magnitude.abs() as u32);
result
}
}
impl BitBoard {
/// Convert from a JON (Jack Othello Notation) string into a usable board.
pub fn from_jon(fen: &str) -> Result<Self, &'static str> {
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().into_iter() {
match c.to_ascii_lowercase() {
'/' => {
line -= 1;
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'..'9' => {
rank_jump = c
.to_digit(10u32)
.ok_or("Failed to parse integer from character")?
as u8;
}
_ => {
return Err("Malformed FEN: Unexpected character encountered");
}
}
}
Ok(Self { boards })
}
/// Compute board with valid moves marked
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 as usize + 1) % 2],
);
// 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 _h_ file. This is because there's no way of placing a piece
// in an orientation such that a piece in the _h_ file is flanked on its
// right side since the board ends. We do the same for when looking
// left with the _a_ file.
for i in 0..8 {
let shift = DIRECTIONS[i];
let mask = DIRECTION_MASKS[i];
// begin performing masks based on where opponent positions are
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
}
}
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 test_masking_logic() {
// validating the tests used in the comment explaining the way we're
// generating moves.
let bb = BitBoard::default();
let blk_shl = bb.boards[Team::Black as usize] << 1;
// validate that they are shifted to the right
assert_eq!(blk_shl, (1 << 37) + (1 << 28));
// validate that they are shifted to the right visually
let expected_output = r"--------
--------
--------
-----B--
----B---
--------
--------
--------";
assert_eq!(
format!(
"{}",
BitBoard {
boards: [blk_shl, 0]
}
),
expected_output
);
// test that we can mask with white squares.
let matched = blk_shl & bb.boards[Team::White as usize];
// validate that they are matching only
assert_eq!(matched, !Board::MAX + (1 << 28));
// validate that they match visually
let expected_output = r"--------
--------
--------
--------
----B---
--------
--------
--------";
assert_eq!(
format!(
"{}",
BitBoard {
boards: [matched, 0]
}
),
expected_output
);
}
#[test]
fn avaliable_works() {
let bb = BitBoard::default();
assert_eq!(bb.available(Team::Black), D6 + C5 + F4 + E3);
}
#[test]
fn display_works() {
let bb = BitBoard::default();
let expected_output = r"--------
--------
--------
---WB---
---BW---
--------
--------
--------";
assert_eq!(format!("{}", bb), expected_output);
}
#[test]
fn jon_works() {
let bb = BitBoard::from_jon("///3wb/3bw///").expect("Starting board should be valid");
println!("{}", bb);
assert_eq!(bb.boards, BitBoard::default().boards);
}
#[test]
fn test_available_starting_position_black() {
// Starting position
let bb = BitBoard::from_jon("///3wb/3bw///").expect("Valid board");
let available = bb.available(Team::Black);
// Black can move to: d3, c4, f5, e6
let expected = BitBoard::from_jon("//4b/5b/2b/3b//").expect("Valid board");
assert_eq!(available, expected.boards[Team::Black as usize]);
}
#[test]
fn test_available_starting_position_white() {
// Starting position
let bb = BitBoard::from_jon("///3wb/3bw///").expect("Valid board");
let available = bb.available(Team::White);
// White can move to: c5, d6, f4, e3
let expected = BitBoard::from_jon("//3w/5w/2w/4w//").expect("Valid board");
assert_eq!(available, expected.boards[Team::White as usize]);
}
#[test]
fn test_available_empty_board() {
// Empty board - no pieces
let bb = BitBoard::from_jon("////////").expect("Valid board");
let available_black = bb.available(Team::Black);
let available_white = bb.available(Team::White);
// No moves available on empty board
assert_eq!(available_black, 0);
assert_eq!(available_white, 0);
}
#[test]
fn test_available_single_piece_no_moves() {
// Single white piece, no black pieces
let bb = BitBoard::from_jon("///3w////").expect("Valid board");
let available = bb.available(Team::Black);
// Black has no valid moves (needs opponent pieces to capture)
assert_eq!(available, 0);
}
#[test]
fn test_available_corner_move() {
// Board with pieces set up for a corner capture
// Black at b8, white at a8, black can play at a7 to capture
let bb = BitBoard::from_jon("wb//////").expect("Valid board");
println!("{}", bb);
let available = bb.available(Team::Black);
// Black can move to a7 to capture white at a8
let expected = BitBoard::from_jon("/b//////").expect("Valid board");
assert_eq!(available, expected.boards[Team::Black as usize]);
}
#[test]
fn test_available_edge_captures() {
// Black pieces on edges with white pieces that can be captured
let bb = BitBoard::from_jon("b6w/8/8/8/8/8/8/8").expect("Valid board");
let available = bb.available(Team::Black);
// Black can capture by playing in between
let expected = BitBoard::from_jon("1bbbbbb/").expect("Valid board");
assert_eq!(available, expected.boards[Team::Black as usize]);
}
#[test]
fn test_available_multiple_direction_capture() {
// White surrounded by black - black can capture in multiple directions
let bb = BitBoard::from_jon("///2bbb/2bwb/2bbb///").expect("Valid board");
let available = bb.available(Team::Black);
// Black can play at d4 (where white is) - but this is invalid, let me reconsider
// Actually, moves must be on empty squares
// Let's set up where black can capture in multiple directions
let bb = BitBoard::from_jon("///2b1b/2bwb/2bbb///").expect("Valid board");
let available = bb.available(Team::Black);
// Black can move to d5 to capture white
let expected = BitBoard::from_jon("///3b////").expect("Valid board");
assert_eq!(available, expected.boards[Team::Black as usize]);
}
#[test]
fn test_available_no_valid_moves() {
// Position where current team has no valid moves
// Black pieces isolated with no white pieces to capture
let bb = BitBoard::from_jon("b///////w").expect("Valid board");
let available_black = bb.available(Team::Black);
let available_white = bb.available(Team::White);
// Neither player can capture anything
assert_eq!(available_black, 0);
assert_eq!(available_white, 0);
}
#[test]
fn test_available_full_row_capture() {
// Black can capture an entire row of white pieces
let bb = BitBoard::from_jon("///////bwwwwwwb").expect("Valid board");
let available = bb.available(Team::Black);
// Black can play anywhere between the two black pieces on row 1
let expected = BitBoard::from_jon("///////1bbbbbb").expect("Valid board");
assert_eq!(available, expected.boards[Team::Black as usize]);
}
#[test]
fn test_available_diagonal_capture() {
// Test diagonal captures
let bb = BitBoard::from_jon("b/1w/2w/3w////").expect("Valid board");
let available = bb.available(Team::Black);
// Black can capture diagonally
let expected = BitBoard::from_jon("////4b///").expect("Valid board");
assert_eq!(available, expected.boards[Team::Black as usize]);
}
#[test]
fn test_available_midgame_position() {
// More complex mid-game position
let bb = BitBoard::from_jon("//2bwb/2www/2bwb///").expect("Valid board");
let available = bb.available(Team::Black);
// Black should have several valid moves
// This would need to be calculated based on actual game rules
// Adding moves that would flip white pieces
let expected = BitBoard::from_jon("/3b/b1b1b/b3b/b1b1b/3b//").expect("Valid board");
assert_eq!(available, expected.boards[Team::Black as usize]);
}
#[test]
fn test_available_vertical_capture() {
// Test vertical captures
let bb = BitBoard::from_jon("b/w/w/w////").expect("Valid board");
let available = bb.available(Team::Black);
// Black can capture vertically downward
let expected = BitBoard::from_jon("////b///").expect("Valid board");
assert_eq!(available, expected.boards[Team::Black as usize]);
}
}