ai-hex 0.1.0

A small Hex rules and Monte Carlo tree search engine
Documentation
use ai_hex::{BoardSize, Color, GameResult, Move, MoveError, Position, Seat, SwapRule};

fn size(value: u8) -> BoardSize {
    BoardSize::new(value).unwrap()
}

#[test]
fn accepts_every_supported_board_size() {
    assert_eq!(BoardSize::default().get(), 13);
    assert!(BoardSize::new(8).is_err());
    for value in 9..=24 {
        assert_eq!(BoardSize::new(value).unwrap().get(), value);
    }
    assert!(BoardSize::new(25).is_err());
}

#[test]
fn parses_acute_notation_and_swap() {
    let a1 = "a1".parse::<Move>().unwrap();
    let x24 = "X24".parse::<Move>().unwrap();
    assert_eq!(a1.to_string(), "a1");
    assert_eq!(x24.to_string(), "x24");
    assert_eq!("SWAP".parse::<Move>().unwrap(), Move::Swap);
    assert!("i0".parse::<Move>().is_err());
    assert!("y1".parse::<Move>().is_err());
}

#[test]
fn rejects_cells_outside_the_current_board() {
    let position = Position::new(size(9), SwapRule::Enabled);
    assert_eq!(
        position.play("j1".parse().unwrap()),
        Err(MoveError::OutsideBoard)
    );
}

#[test]
fn swap_exchanges_colors_without_moving_the_opening_stone() {
    let position = Position::new(size(15), SwapRule::Enabled)
        .play("h8".parse().unwrap())
        .unwrap();
    assert_eq!(position.seat_to_move(), Seat::Two);
    assert_eq!(position.color_to_move(), Color::Blue);
    assert!(position.swap_available());

    let swapped = position.play(Move::Swap).unwrap();
    assert_eq!(swapped.seat_to_move(), Seat::One);
    assert_eq!(swapped.color_to_move(), Color::Blue);
    assert_eq!(swapped.color_for_seat(Seat::One), Color::Blue);
    assert_eq!(swapped.color_for_seat(Seat::Two), Color::Red);
    assert_eq!(
        swapped.color_at("h8".parse::<Move>().unwrap().cell().unwrap()),
        Some(Color::Red)
    );
    assert!(!swapped.swap_available());
    assert_eq!(swapped.stones(), 1);
    assert_eq!(swapped.actions(), 2);
}

#[test]
fn declining_swap_closes_the_window() {
    let position = Position::new(size(15), SwapRule::Enabled)
        .play("h8".parse().unwrap())
        .unwrap()
        .play("g8".parse().unwrap())
        .unwrap();
    assert!(!position.swap_available());
    assert_eq!(position.play(Move::Swap), Err(MoveError::SwapUnavailable));
}

#[test]
fn disabled_swap_rule_never_offers_swap() {
    let position = Position::new(size(15), SwapRule::Disabled)
        .play("h8".parse().unwrap())
        .unwrap();
    assert!(!position.swap_available());
    assert!(!position.legal_moves().contains(&Move::Swap));
}

#[test]
fn red_connects_top_to_bottom() {
    let mut moves = Vec::new();
    for rank in 1..=9 {
        moves.push(format!("a{rank}").parse::<Move>().unwrap());
        if rank < 9 {
            moves.push(format!("h{rank}").parse::<Move>().unwrap());
        }
    }
    let position = Position::from_moves(size(9), SwapRule::Disabled, &moves).unwrap();
    assert_eq!(position.result(), GameResult::Win(Seat::One));
    assert_eq!(position.winning_path().len(), 9);
    assert_eq!(position.winning_path().first().unwrap().rank(), 0);
    assert_eq!(position.winning_path().last().unwrap().rank(), 8);
    assert!(position.legal_moves().is_empty());
}

#[test]
fn blue_connects_left_to_right() {
    let mut moves = Vec::new();
    for file in 0..9 {
        moves.push(Move::place(file, 0).unwrap());
        moves.push(Move::place(file, 8).unwrap());
    }
    let position = Position::from_moves(size(9), SwapRule::Disabled, &moves).unwrap();
    assert_eq!(position.result(), GameResult::Win(Seat::Two));
    assert_eq!(position.winning_path().len(), 9);
    assert_eq!(position.winning_path().first().unwrap().file(), 0);
    assert_eq!(position.winning_path().last().unwrap().file(), 8);
}

#[test]
fn diagonal_hex_neighbors_form_a_connection() {
    let red = ["e1", "d2", "c3", "b4", "a5", "a6", "a7", "a8", "a9"];
    let blue = ["h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8"];
    let moves = red
        .iter()
        .zip(blue)
        .flat_map(|(red, blue)| [red.parse().unwrap(), blue.parse().unwrap()])
        .chain(std::iter::once(red[8].parse().unwrap()))
        .collect::<Vec<_>>();
    let position = Position::from_moves(size(9), SwapRule::Disabled, &moves).unwrap();
    assert_eq!(position.result(), GameResult::Win(Seat::One));
}

#[test]
fn random_games_end_with_a_connected_winning_path() {
    let mut random = SplitMix64(0xdecafbad);
    for board_size in [9, 15, 24] {
        for _ in 0..24 {
            let mut position = Position::new(size(board_size), SwapRule::Enabled);
            while position.result() == GameResult::Ongoing {
                let moves = position.legal_moves();
                let mv = moves[random.index(moves.len())];
                position = position.play(mv).unwrap();
            }

            let GameResult::Win(winner) = position.result() else {
                unreachable!();
            };
            let color = position.color_for_seat(winner);
            let path = position.winning_path();
            assert!(!path.is_empty());
            assert!(
                path.iter()
                    .all(|&cell| position.color_at(cell) == Some(color))
            );
            assert!(path.windows(2).all(|pair| {
                let file = i16::from(pair[1].file()) - i16::from(pair[0].file());
                let rank = i16::from(pair[1].rank()) - i16::from(pair[0].rank());
                matches!(
                    (file, rank),
                    (1, 0) | (-1, 0) | (0, 1) | (0, -1) | (1, -1) | (-1, 1)
                )
            }));
            match color {
                Color::Red => {
                    assert_eq!(path.first().unwrap().rank(), 0);
                    assert_eq!(path.last().unwrap().rank(), board_size - 1);
                }
                Color::Blue => {
                    assert_eq!(path.first().unwrap().file(), 0);
                    assert_eq!(path.last().unwrap().file(), board_size - 1);
                }
            }
            assert!(position.actions() <= u16::from(board_size) * u16::from(board_size) + 1);
        }
    }
}

struct SplitMix64(u64);

impl SplitMix64 {
    fn next(&mut self) -> u64 {
        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
        let mut value = self.0;
        value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
        value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
        value ^ (value >> 31)
    }

    fn index(&mut self, length: usize) -> usize {
        (self.next() as usize) % length
    }
}