/* Copyright 2024, Savanni D'Gerinel This file is part of On the Grid. On the Grid is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. On the Grid is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with On the Grid. If not, see . */ // TBQH, I don't recall what state this object is in, but I do know that I might have some troubles // integrating it with a game record. Some of the time here is going to be me reading (and // documenting) my code from almost a year ago. // use crate::{BoardError, Color, Size}; use sgf::{GameNode, MoveNode}; use std::collections::HashSet; #[derive(Clone, Debug, Default)] pub struct Goban { /// The size of the board. Usually this is symetrical, but I have actually played a 5x25 game. /// These are fun for novelty, but don't lend much to understanding the game. pub size: Size, /// I found that it was easiest to track groups of stones than to track individual stones on the /// board. So, I just keep track of all of the groups. pub groups: Vec, } impl std::fmt::Display for Goban { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { write!(f, " ")?; // for c in 'A'..'U' { for c in 0..19 { write!(f, "{:2}", c)?; } writeln!(f)?; for row in 0..self.size.height { write!(f, " {:2}", row)?; for column in 0..self.size.width { match self.stone(&Coordinate { column, row }) { None => write!(f, " .")?, Some(Color::Black) => write!(f, " X")?, Some(Color::White) => write!(f, " O")?, } } writeln!(f)?; } Ok(()) } } impl PartialEq for Goban { fn eq(&self, other: &Self) -> bool { if self.size != other.size { return false; } for group in self.groups.iter() { if !other.groups.contains(group) { return false; } } for group in other.groups.iter() { if !self.groups.contains(group) { return false; } } true } } impl Goban { pub fn new() -> Self { Self { size: Size { width: 19, height: 19, }, groups: Vec::new(), } } /// Generate a board state from an iterator of coordinates and the color of any stone present on /// the board. As we walk through the iterator, we play each stone as though it were being /// played in a game. /// /// This would not work at all if we wanted to set up an impossible board state, given that /// groups of stones get automatically removed once surrounded. pub fn from_coordinates( coordinates: impl IntoIterator, ) -> Result { coordinates .into_iter() .try_fold(Self::new(), |board, (coordinate, color)| { board.place_stone(coordinate, color) }) } } #[derive(Clone, Copy, Debug, PartialEq, Hash, Eq)] pub struct Coordinate { pub column: u8, pub row: u8, } impl Goban { /// place_stone is the most fundamental function of this object. This is as though a player put /// a stone on the board and evaluated the consequences. /// /// This function does not enforce turn order. /// /// # Examples /// /// ``` /// use otg_core::{Color, Size, Coordinate, Goban}; /// use cool_asserts::assert_matches; /// /// let goban = Goban::new(); /// assert_eq!(goban.size, Size{ width: 19, height: 19 }); /// let move_result = goban.place_stone(Coordinate{ column: 4, row: 4 }, Color::Black); /// assert_matches!(move_result, Goban); /// ``` pub fn place_stone(mut self, coordinate: Coordinate, color: Color) -> Result { // Bail out immediately if there is already a stone at this location. if self.stone(&coordinate).is_some() { return Err(BoardError::InvalidPosition); } // Find all friendly groups adjacent to this stone. First, calculate the adjacent // coordinates. Then see if there is any group which contains that coordinate. If not, this // stone forms a new group of its own. // // A little subtle here is that this stone will be added to *every* adjoining friendly // group. This normally means only that a group gets bigger, but it could also cause two // groups to share a stone, which means they're now a single group. let mut friendly_group = self .adjacencies(&coordinate) .into_iter() .filter(|c| self.stone(c) == Some(color)) .filter_map(|c| self.group(&c).map(|g| g.coordinates.clone())) // In fact, this last step actually connects the coordinates of those friendly groups // into a single large group. .fold(HashSet::new(), |acc, set| { acc.union(&set).cloned().collect() }); // This is a little misnamed. This is a HashSet, not a full Group. friendly_group.insert(coordinate); // Remove all groups which contain the stones overlapping with this friendly group. self.groups .retain(|g| g.coordinates.is_disjoint(&friendly_group)); // Generate a new friendly group given the coordinates. let friendly_group = Group { color, coordinates: friendly_group, }; // Now add the group back to the board. self.groups.push(friendly_group.clone()); // Now, find all groups adjacent to this one. Those are the only groups that this move is // going to impact. Calculate their liberties. let adjacent_groups = self.adjacent_groups(&friendly_group); for group in adjacent_groups { // Any group that has been reduced to 0 liberties should now be removed from the board. // // TODO: capture rules: we're not counting captured stones yet. Okay with some scoring // methods, but not all. if self.liberties(&group) == 0 { self.remove_group(&group); } } // Now, recalculate the liberties of this friendly group. If this group has been reduced to // zero liberties, after all captures have been accounted for, the move is an illegal // self-capture. Drop all of the work we've done and return an error. if self.liberties(&friendly_group) == 0 { return Err(BoardError::SelfCapture); } Ok(self) } /// Apply a list of moves to the board and return the final board. The moves will be played as /// though they are live moves played normally, but this function is for generating a board /// state from a game record. All of the moves will be played in the order given. This does not /// allow for the branching which is natural in a game review. /// /// # Examples /// /// ``` /// use otg_core::{Color, Size, Coordinate, Goban}; /// use cool_asserts::assert_matches; /// use sgf::{GameNode, MoveNode, Move}; /// /// let goban = Goban::new(); /// let moves = vec![ /// GameNode::MoveNode(MoveNode::new(sgf::Color::Black, Move::Move("dd".to_owned()))), /// GameNode::MoveNode(MoveNode::new(sgf::Color::White, Move::Move("pp".to_owned()))), /// GameNode::MoveNode(MoveNode::new(sgf::Color::Black, Move::Move("dp".to_owned()))), /// ]; /// let moves_: Vec<&GameNode> = moves.iter().collect(); /// let goban = goban.apply_moves(moves_).expect("the test to have valid moves"); /// /// assert_eq!(goban.stone(&Coordinate{ row: 3, column: 3 }), Some(Color::Black)); /// assert_eq!(goban.stone(&Coordinate{ row: 15, column: 15 }), Some(Color::White)); /// assert_eq!(goban.stone(&Coordinate{ row: 15, column: 3 }), Some(Color::Black)); /// ``` pub fn apply_moves<'a>( self, moves: impl IntoIterator, ) -> Result { let mut s = self; for m in moves.into_iter() { match m { GameNode::MoveNode(node) => s = s.apply_move_node(node)?, GameNode::SetupNode(_n) => unimplemented!("setup nodes aren't processed yet"), }; } Ok(s) } fn apply_move_node(self, m: &MoveNode) -> Result { if let Some((row, column)) = m.mv.coordinate() { self.place_stone(Coordinate { row, column }, Color::from(&m.color)) } else { Ok(self) } } pub fn stone(&self, coordinate: &Coordinate) -> Option { self.groups .iter() .find(|g| g.contains(coordinate)) .map(|g| g.color) } fn group(&self, coordinate: &Coordinate) -> Option<&Group> { self.groups .iter() .find(|g| g.coordinates.contains(coordinate)) } fn remove_group(&mut self, group: &Group) { self.groups.retain(|g| g != group); } fn adjacent_groups(&self, group: &Group) -> Vec { let adjacent_spaces = self.group_halo(group).into_iter(); let mut grps: Vec = Vec::new(); adjacent_spaces.for_each(|coord| match self.group(&coord) { None => {} Some(adj) => { if group.color == adj.color { return; } if grps.iter().any(|g| g.coordinates.contains(&coord)) { return; } grps.push(adj.clone()); } }); grps } fn group_halo(&self, group: &Group) -> HashSet { group .coordinates .iter() .flat_map(|c| self.adjacencies(c)) .collect::>() } fn liberties(&self, group: &Group) -> usize { self.group_halo(group) .into_iter() .filter(|c| self.stone(c).is_none()) .count() } fn adjacencies(&self, coordinate: &Coordinate) -> Vec { let mut v = Vec::new(); if coordinate.column > 0 { v.push(Coordinate { column: coordinate.column - 1, row: coordinate.row, }); } if coordinate.row > 0 { v.push(Coordinate { column: coordinate.column, row: coordinate.row - 1, }); } v.push(Coordinate { column: coordinate.column + 1, row: coordinate.row, }); v.push(Coordinate { column: coordinate.column, row: coordinate.row + 1, }); v.into_iter().filter(|c| self.within_board(c)).collect() } fn within_board(&self, coordinate: &Coordinate) -> bool { coordinate.column < self.size.width && coordinate.row < self.size.height } } #[derive(Clone, Debug, PartialEq)] pub struct Group { color: Color, coordinates: HashSet, } impl Group { fn contains(&self, coordinate: &Coordinate) -> bool { self.coordinates.contains(coordinate) } } #[cfg(test)] mod test { use super::*; /* Two players (Black and White) take turns and Black plays first * Stones are placed on the line intersections and not moved. * A stone with no liberties is removed from the board. * A group of stones of the same color share liberties. * A stone at the edge of the board has only three liberties. * A stone at the corner of the board has only two liberties. * A stone may not be placed in a suicidal position. * A stone placed in a suicidal position is legal if it captures other stones first. */ fn with_example_board(test: impl FnOnce(Goban)) { let board = Goban::from_coordinates( vec![ (Coordinate { column: 3, row: 3 }, Color::White), (Coordinate { column: 3, row: 4 }, Color::White), /* */ (Coordinate { column: 8, row: 3 }, Color::Black), (Coordinate { column: 9, row: 3 }, Color::Black), (Coordinate { column: 9, row: 4 }, Color::Black), /* */ (Coordinate { column: 15, row: 3 }, Color::White), (Coordinate { column: 15, row: 4 }, Color::White), (Coordinate { column: 15, row: 5 }, Color::White), (Coordinate { column: 14, row: 4 }, Color::White), /* */ (Coordinate { column: 3, row: 8 }, Color::White), (Coordinate { column: 3, row: 9 }, Color::White), (Coordinate { column: 4, row: 9 }, Color::White), (Coordinate { column: 3, row: 10 }, Color::Black), /* */ (Coordinate { column: 0, row: 0 }, Color::White), (Coordinate { column: 1, row: 0 }, Color::White), (Coordinate { column: 0, row: 1 }, Color::White), /* */ (Coordinate { column: 9, row: 9 }, Color::White), (Coordinate { column: 8, row: 9 }, Color::Black), (Coordinate { column: 9, row: 8 }, Color::Black), (Coordinate { column: 9, row: 10 }, Color::Black), /* */ (Coordinate { column: 0, row: 17 }, Color::White), (Coordinate { column: 1, row: 18 }, Color::White), (Coordinate { column: 0, row: 18 }, Color::White), (Coordinate { column: 0, row: 16 }, Color::Black), (Coordinate { column: 1, row: 17 }, Color::Black), /* */ (Coordinate { column: 4, row: 17 }, Color::Black), (Coordinate { column: 5, row: 17 }, Color::Black), (Coordinate { column: 6, row: 17 }, Color::Black), (Coordinate { column: 4, row: 18 }, Color::Black), (Coordinate { column: 6, row: 18 }, Color::Black), (Coordinate { column: 3, row: 17 }, Color::White), (Coordinate { column: 3, row: 18 }, Color::White), (Coordinate { column: 4, row: 16 }, Color::White), (Coordinate { column: 5, row: 16 }, Color::White), (Coordinate { column: 6, row: 16 }, Color::White), (Coordinate { column: 7, row: 17 }, Color::White), (Coordinate { column: 7, row: 18 }, Color::White), /* */ (Coordinate { column: 17, row: 0 }, Color::White), (Coordinate { column: 17, row: 1 }, Color::White), (Coordinate { column: 18, row: 1 }, Color::White), ] .into_iter(), ) .unwrap(); test(board); } #[test] fn it_gets_adjacencies_for_coordinate() { let board = Goban::new(); for column in 0..19 { for row in 0..19 { for coordinate in board.adjacencies(&Coordinate { column, row }) { assert!( board.within_board(&coordinate), "{} {}: {:?}", column, row, coordinate ); } } } } #[test] fn it_counts_individual_liberties() { let board = Goban::from_coordinates( vec![ (Coordinate { column: 3, row: 3 }, Color::White), (Coordinate { column: 0, row: 3 }, Color::White), (Coordinate { column: 0, row: 0 }, Color::White), (Coordinate { column: 18, row: 9 }, Color::Black), ( Coordinate { column: 18, row: 18, }, Color::Black, ), ] .into_iter(), ) .unwrap(); assert!(board.group(&Coordinate { column: 18, row: 3 }).is_none()); assert_eq!( board .group(&Coordinate { column: 3, row: 3 }) .map(|g| board.liberties(&g)), Some(4) ); assert_eq!( board .group(&Coordinate { column: 0, row: 3 }) .map(|g| board.liberties(&g)), Some(3) ); assert_eq!( board .group(&Coordinate { column: 0, row: 0 }) .map(|g| board.liberties(&g)), Some(2) ); assert_eq!( board .group(&Coordinate { column: 18, row: 9 }) .map(|g| board.liberties(&g)), Some(3) ); assert_eq!( board .group(&Coordinate { column: 18, row: 18 }) .map(|g| board.liberties(&g)), Some(2) ); } #[test] fn stones_share_liberties() { with_example_board(|board: Goban| { let test_cases = vec![ ( board.clone(), Coordinate { column: 0, row: 0 }, Some(Group { color: Color::White, coordinates: vec![ Coordinate { column: 0, row: 0 }, Coordinate { column: 1, row: 0 }, Coordinate { column: 0, row: 1 }, ] .into_iter() .collect(), }), Some(3), ), ( board.clone(), Coordinate { column: 1, row: 0 }, Some(Group { color: Color::White, coordinates: vec![ Coordinate { column: 0, row: 0 }, Coordinate { column: 1, row: 0 }, Coordinate { column: 0, row: 1 }, ] .into_iter() .collect(), }), Some(3), ), ( board.clone(), Coordinate { column: 9, row: 9 }, Some(Group { color: Color::White, coordinates: vec![Coordinate { column: 9, row: 9 }].into_iter().collect(), }), Some(1), ), ( board.clone(), Coordinate { column: 3, row: 4 }, Some(Group { color: Color::White, coordinates: vec![ Coordinate { column: 3, row: 3 }, Coordinate { column: 3, row: 4 }, ] .into_iter() .collect(), }), Some(6), ), ( board.clone(), Coordinate { column: 9, row: 3 }, Some(Group { color: Color::Black, coordinates: vec![ Coordinate { column: 8, row: 3 }, Coordinate { column: 9, row: 3 }, Coordinate { column: 9, row: 4 }, ] .into_iter() .collect(), }), Some(7), ), ( board.clone(), Coordinate { column: 15, row: 4 }, Some(Group { color: Color::White, coordinates: vec![ Coordinate { column: 15, row: 3 }, Coordinate { column: 15, row: 4 }, Coordinate { column: 15, row: 5 }, Coordinate { column: 14, row: 4 }, ] .into_iter() .collect(), }), Some(8), ), ( board.clone(), Coordinate { column: 3, row: 9 }, Some(Group { color: Color::White, coordinates: vec![ Coordinate { column: 3, row: 8 }, Coordinate { column: 3, row: 9 }, Coordinate { column: 4, row: 9 }, ] .into_iter() .collect(), }), Some(6), ), ( board.clone(), Coordinate { column: 0, row: 18 }, Some(Group { color: Color::White, coordinates: vec![ Coordinate { column: 0, row: 17 }, Coordinate { column: 0, row: 18 }, Coordinate { column: 1, row: 18 }, ] .into_iter() .collect(), }), Some(1), ), ( board.clone(), Coordinate { column: 0, row: 17 }, Some(Group { color: Color::White, coordinates: vec![ Coordinate { column: 0, row: 17 }, Coordinate { column: 0, row: 18 }, Coordinate { column: 1, row: 18 }, ] .into_iter() .collect(), }), Some(1), ), ]; for (board, coordinate, group, liberties) in test_cases { assert_eq!(board.group(&coordinate), group.as_ref()); assert_eq!( board.group(&coordinate).map(|g| board.liberties(&g)), liberties, "{:?}", coordinate ); } }); } #[test] fn it_finds_adjacent_groups() { with_example_board(|board| { let group = board .group(&Coordinate { column: 0, row: 0 }) .cloned() .unwrap(); assert_eq!(board.adjacent_groups(&group), Vec::new()); let group = board .group(&Coordinate { column: 3, row: 10 }) .cloned() .unwrap(); assert_eq!(board.adjacent_groups(&group).len(), 1); }); } #[test] fn surrounding_a_group_removes_it() { with_example_board(|board| { let board = board .place_stone(Coordinate { column: 10, row: 9 }, Color::Black) .unwrap(); assert!(board.stone(&Coordinate { column: 9, row: 9 }).is_none()); let board = board .place_stone(Coordinate { column: 2, row: 18 }, Color::Black) .unwrap(); assert!(board.stone(&Coordinate { column: 0, row: 18 }).is_none()); assert!(board.stone(&Coordinate { column: 1, row: 18 }).is_none()); assert!(board.stone(&Coordinate { column: 0, row: 17 }).is_none()); assert!(board.group(&Coordinate { column: 0, row: 18 }).is_none()); let board = board .place_stone(Coordinate { column: 5, row: 18 }, Color::White) .unwrap(); assert!(board.stone(&Coordinate { column: 4, row: 17 }).is_none()); assert!(board.stone(&Coordinate { column: 5, row: 17 }).is_none()); assert!(board.stone(&Coordinate { column: 6, row: 17 }).is_none()); assert!(board.stone(&Coordinate { column: 4, row: 18 }).is_none()); assert!(board.stone(&Coordinate { column: 6, row: 18 }).is_none()); }); } #[test] fn self_capture_is_forbidden() { with_example_board(|board| { { let board = board.clone(); let res = board.place_stone(Coordinate { column: 18, row: 0 }, Color::Black); assert_eq!(res, Err(BoardError::SelfCapture)); } { let board = board.clone(); let res = board.place_stone(Coordinate { column: 5, row: 18 }, Color::Black); assert_eq!(res, Err(BoardError::SelfCapture)); } }); } #[test] fn validate_group_comparisons() { { let b1 = Goban::from_coordinates( vec![(Coordinate { column: 7, row: 9 }, Color::White)].into_iter(), ) .unwrap(); let b2 = Goban::from_coordinates( vec![(Coordinate { column: 7, row: 9 }, Color::White)].into_iter(), ) .unwrap(); assert_eq!(b1, b2); } { let b1 = Goban::from_coordinates( vec![ (Coordinate { column: 7, row: 9 }, Color::White), (Coordinate { column: 8, row: 10 }, Color::White), ] .into_iter(), ) .unwrap(); let b2 = Goban::from_coordinates( vec![ (Coordinate { column: 8, row: 10 }, Color::White), (Coordinate { column: 7, row: 9 }, Color::White), ] .into_iter(), ) .unwrap(); assert_eq!(b1, b2); } } #[test] fn two_boards_can_be_compared() { let board = Goban::from_coordinates( vec![ (Coordinate { column: 7, row: 9 }, Color::White), (Coordinate { column: 8, row: 8 }, Color::White), (Coordinate { column: 8, row: 10 }, Color::White), (Coordinate { column: 9, row: 9 }, Color::White), (Coordinate { column: 10, row: 9 }, Color::Black), (Coordinate { column: 9, row: 8 }, Color::Black), (Coordinate { column: 9, row: 10 }, Color::Black), ] .into_iter(), ) .unwrap(); let b1 = board .clone() .place_stone(Coordinate { column: 8, row: 9 }, Color::Black) .unwrap(); let b2 = b1 .clone() .place_stone(Coordinate { column: 9, row: 9 }, Color::White) .unwrap(); assert_eq!(board, b2); } }