monorepo/otg/core/src/goban.rs

753 lines
27 KiB
Rust
Raw Normal View History

/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
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 <https://www.gnu.org/licenses/>.
*/
// 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.
//
2023-05-04 02:22:22 +00:00
use crate::{BoardError, Color, Size};
2024-03-26 12:47:42 +00:00
use sgf::{GameNode, MoveNode};
use std::collections::HashSet;
2023-10-05 16:19:57 +00:00
#[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<Group>,
}
impl std::fmt::Display for Goban {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
2023-05-04 02:22:22 +00:00
write!(f, " ")?;
// for c in 'A'..'U' {
for c in 0..19 {
2023-05-04 02:22:22 +00:00
write!(f, "{:2}", c)?;
}
2023-10-05 16:19:57 +00:00
writeln!(f)?;
for row in 0..self.size.height {
2023-05-04 02:22:22 +00:00
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")?,
}
}
2023-10-05 16:19:57 +00:00
writeln!(f)?;
}
Ok(())
}
}
impl PartialEq for Goban {
2023-05-04 02:22:22 +00:00
fn eq(&self, other: &Self) -> bool {
if self.size != other.size {
return false;
}
for group in self.groups.iter() {
2023-10-05 16:19:57 +00:00
if !other.groups.contains(group) {
2023-05-04 02:22:22 +00:00
return false;
}
}
for group in other.groups.iter() {
2023-10-05 16:19:57 +00:00
if !self.groups.contains(group) {
2023-05-04 02:22:22 +00:00
return false;
}
}
2023-10-05 16:19:57 +00:00
true
2023-05-04 02:22:22 +00:00
}
}
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.
2023-05-04 02:22:22 +00:00
pub fn from_coordinates(
2024-03-23 18:41:35 +00:00
coordinates: impl IntoIterator<Item = (Coordinate, Color)>,
2023-05-04 02:22:22 +00:00
) -> Result<Self, BoardError> {
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 {
2023-05-11 13:39:31 +00:00
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);
/// ```
2023-05-04 02:22:22 +00:00
pub fn place_stone(mut self, coordinate: Coordinate, color: Color) -> Result<Self, BoardError> {
// Bail out immediately if there is already a stone at this location.
2023-10-05 16:19:57 +00:00
if self.stone(&coordinate).is_some() {
2023-05-04 02:22:22 +00:00
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.
2023-10-05 16:19:57 +00:00
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.
2023-05-04 00:49:20 +00:00
if self.liberties(&friendly_group) == 0 {
2023-05-04 02:22:22 +00:00
return Err(BoardError::SelfCapture);
2023-05-04 00:49:20 +00:00
}
2023-05-04 02:22:22 +00:00
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<Item = &'a GameNode>,
) -> Result<Goban, BoardError> {
let mut s = self;
for m in moves.into_iter() {
2024-03-26 12:47:42 +00:00
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<Goban, BoardError> {
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<Color> {
2023-05-04 00:42:03 +00:00
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<Group> {
let adjacent_spaces = self.group_halo(group).into_iter();
let mut grps: Vec<Group> = Vec::new();
adjacent_spaces.for_each(|coord| match self.group(&coord) {
2023-10-05 16:19:57 +00:00
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<Coordinate> {
group
.coordinates
.iter()
2023-10-05 16:19:57 +00:00
.flat_map(|c| self.adjacencies(c))
.collect::<HashSet<Coordinate>>()
}
fn liberties(&self, group: &Group) -> usize {
self.group_halo(group)
.into_iter()
2023-10-05 16:19:57 +00:00
.filter(|c| self.stone(c).is_none())
.count()
}
fn adjacencies(&self, coordinate: &Coordinate) -> Vec<Coordinate> {
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<Coordinate>,
}
2023-05-04 00:42:03 +00:00
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),
2023-05-04 00:49:20 +00:00
/* */
(Coordinate { column: 17, row: 0 }, Color::White),
(Coordinate { column: 17, row: 1 }, Color::White),
(Coordinate { column: 18, row: 1 }, Color::White),
]
.into_iter(),
2023-05-04 02:22:22 +00:00
)
.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(),
2023-05-04 02:22:22 +00:00
)
.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() {
2023-05-04 02:22:22 +00:00
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() {
2023-05-04 02:22:22 +00:00
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());
2023-05-04 02:22:22 +00:00
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());
2023-05-04 02:22:22 +00:00
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());
});
}
2023-05-04 00:49:20 +00:00
#[test]
fn self_capture_is_forbidden() {
2023-05-04 02:22:22 +00:00
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));
}
2023-05-04 00:49:20 +00:00
2023-05-04 02:22:22 +00:00
{
let board = board.clone();
let res = board.place_stone(Coordinate { column: 5, row: 18 }, Color::Black);
assert_eq!(res, Err(BoardError::SelfCapture));
}
2023-05-04 00:49:20 +00:00
});
}
2023-05-04 02:22:22 +00:00
#[test]
fn validate_group_comparisons() {
{
let b1 = Goban::from_coordinates(
2023-05-04 02:22:22 +00:00
vec![(Coordinate { column: 7, row: 9 }, Color::White)].into_iter(),
)
.unwrap();
let b2 = Goban::from_coordinates(
2023-05-04 02:22:22 +00:00
vec![(Coordinate { column: 7, row: 9 }, Color::White)].into_iter(),
)
.unwrap();
assert_eq!(b1, b2);
}
{
let b1 = Goban::from_coordinates(
2023-05-04 02:22:22 +00:00
vec![
(Coordinate { column: 7, row: 9 }, Color::White),
(Coordinate { column: 8, row: 10 }, Color::White),
]
.into_iter(),
)
.unwrap();
let b2 = Goban::from_coordinates(
2023-05-04 02:22:22 +00:00
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(
2023-05-04 02:22:22 +00:00
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);
}
}