From 91e27ced6b0c6ac7e2fbdcb48e4f5a8557410c94 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Sat, 23 Mar 2024 14:14:53 -0400 Subject: [PATCH] Document the Goban representation in Core --- otg/core/src/board.rs | 93 +++++++++++++++++++++++++++++++++++----- otg/core/src/database.rs | 16 +++---- otg/core/src/library.rs | 6 +-- 3 files changed, 94 insertions(+), 21 deletions(-) diff --git a/otg/core/src/board.rs b/otg/core/src/board.rs index c42fc68..d82e64c 100644 --- a/otg/core/src/board.rs +++ b/otg/core/src/board.rs @@ -1,9 +1,35 @@ +/* +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 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, } @@ -62,10 +88,16 @@ impl Goban { } } + /// 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( - mut coordinates: impl Iterator, + mut coordinates: impl IntoIterator, ) -> Result { - coordinates.try_fold(Self::new(), |board, (coordinate, color)| { + coordinates.into_iter().try_fold(Self::new(), |board, (coordinate, color)| { board.place_stone(coordinate, color) }) } @@ -78,37 +110,78 @@ pub struct Coordinate { } 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); } @@ -116,24 +189,24 @@ impl Goban { Ok(self) } - pub fn stone(&self, coordinate: &Coordinate) -> Option { + fn stone(&self, coordinate: &Coordinate) -> Option { self.groups .iter() .find(|g| g.contains(coordinate)) .map(|g| g.color) } - pub fn group(&self, coordinate: &Coordinate) -> Option<&Group> { + fn group(&self, coordinate: &Coordinate) -> Option<&Group> { self.groups .iter() .find(|g| g.coordinates.contains(coordinate)) } - pub fn remove_group(&mut self, group: &Group) { + fn remove_group(&mut self, group: &Group) { self.groups.retain(|g| g != group); } - pub fn adjacent_groups(&self, group: &Group) -> Vec { + fn adjacent_groups(&self, group: &Group) -> Vec { let adjacent_spaces = self.group_halo(group).into_iter(); let mut grps: Vec = Vec::new(); @@ -153,7 +226,7 @@ impl Goban { grps } - pub fn group_halo(&self, group: &Group) -> HashSet { + fn group_halo(&self, group: &Group) -> HashSet { group .coordinates .iter() @@ -161,14 +234,14 @@ impl Goban { .collect::>() } - pub fn liberties(&self, group: &Group) -> usize { + fn liberties(&self, group: &Group) -> usize { self.group_halo(group) .into_iter() .filter(|c| self.stone(c).is_none()) .count() } - pub fn adjacencies(&self, coordinate: &Coordinate) -> Vec { + fn adjacencies(&self, coordinate: &Coordinate) -> Vec { let mut v = Vec::new(); if coordinate.column > 0 { v.push(Coordinate { @@ -193,7 +266,7 @@ impl Goban { v.into_iter().filter(|c| self.within_board(c)).collect() } - pub fn within_board(&self, coordinate: &Coordinate) -> bool { + fn within_board(&self, coordinate: &Coordinate) -> bool { coordinate.column < self.size.width && coordinate.row < self.size.height } } diff --git a/otg/core/src/database.rs b/otg/core/src/database.rs index 796d0ca..909f55d 100644 --- a/otg/core/src/database.rs +++ b/otg/core/src/database.rs @@ -1,6 +1,6 @@ use std::{io::Read, path::PathBuf}; -use sgf::{parse_sgf, Game}; +use sgf::{parse_sgf, GameRecord}; use thiserror::Error; #[derive(Error, Debug)] @@ -21,12 +21,12 @@ impl From for Error { #[derive(Debug)] pub struct Database { - games: Vec, + games: Vec, } impl Database { pub fn open_path(path: PathBuf) -> Result { - let mut games: Vec = Vec::new(); + let mut games: Vec = Vec::new(); let extension = PathBuf::from("sgf").into_os_string(); @@ -59,7 +59,7 @@ impl Database { Ok(Database { games }) } - pub fn all_games(&self) -> impl Iterator { + pub fn all_games(&self) -> impl Iterator { self.games.iter() } } @@ -84,11 +84,11 @@ mod test { Database::open_path(PathBuf::from("fixtures/five_games/")).expect("database to open"); assert_eq!(db.all_games().count(), 5); - assert_matches!(db.all_games().find(|g| g.info.black_player == Some("Steve".to_owned())), + assert_matches!(db.all_games().find(|g| g.black_player.name == Some("Steve".to_owned())), Some(game) => { - assert_eq!(game.info.black_player, Some("Steve".to_owned())); - assert_eq!(game.info.white_player, Some("Savanni".to_owned())); - assert_eq!(game.info.date, vec![Date::Date(chrono::NaiveDate::from_ymd_opt(2023, 4, 19).unwrap())]); + assert_eq!(game.black_player.name, Some("Steve".to_owned())); + assert_eq!(game.white_player.name, Some("Savanni".to_owned())); + assert_eq!(game.dates, vec![Date::Date(chrono::NaiveDate::from_ymd_opt(2023, 4, 19).unwrap())]); // assert_eq!(game.info.komi, Some(6.5)); } ); diff --git a/otg/core/src/library.rs b/otg/core/src/library.rs index ad8b861..4ac1457 100644 --- a/otg/core/src/library.rs +++ b/otg/core/src/library.rs @@ -16,7 +16,7 @@ You should have received a copy of the GNU General Public License along with On use crate::{Core, Config}; use serde::{Deserialize, Serialize}; -use sgf::Game; +use sgf::GameRecord; #[derive(Clone, Debug, Serialize, Deserialize)] pub enum LibraryRequest { @@ -25,14 +25,14 @@ pub enum LibraryRequest { #[derive(Clone, Debug, Serialize, Deserialize)] pub enum LibraryResponse { - Games(Vec) + Games(Vec) } async fn handle_list_games(model: &Core) -> LibraryResponse { let library = model.library(); match *library { Some(ref library) => { - let info = library.all_games().map(|g| g.clone()).collect::>(); + let info = library.all_games().map(|g| g.clone()).collect::>(); LibraryResponse::Games(info) } None => LibraryResponse::Games(vec![]),