restructured, flips working, benchmarking flips, got some basic terminal rendering

This commit is contained in:
jackjohn7 2025-11-04 01:04:50 -06:00
parent 10de749e1d
commit d7d8732904
17 changed files with 331 additions and 822 deletions

710
src/board/mod.rs Normal file
View file

@ -0,0 +1,710 @@
//! 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<Self, String> {
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<Team> {
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<View>, overlays: Vec<Overlay>) -> 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<dyn Iterator<Item = i32>>,
View::RankDesc => Box::new(raw_r_range.rev()) as Box<dyn Iterator<Item = i32>>,
};
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));
}
}