diff --git a/Makefile b/Makefile index 27bdf2f..87d5286 100644 --- a/Makefile +++ b/Makefile @@ -35,3 +35,17 @@ ifc-dev: ifc-test: cd ifc && make test +kifu-core/dev: + cd kifu/kifu-core && make test + +kifu-core/test: + cd kifu/kifu-core && make test + +kifu-core/test-oneshot: + cd kifu/kifu-core && make test-oneshot + +kifu-gtk: + cd kifu/kifu-gtk && make release + +kifu-gtk/dev: + cd kifu/kifu-gtk && make dev diff --git a/flake.nix b/flake.nix index d44619f..e223b90 100644 --- a/flake.nix +++ b/flake.nix @@ -38,6 +38,7 @@ pkgs.pkg-config pkgs.sqlite pkgs.rustup + pkgs.cargo-nextest pkgs.crate2nix ]; LIBCLANG_PATH="${pkgs.llvmPackages.libclang.lib}/lib"; diff --git a/kifu/kifu-core/Cargo.lock b/kifu/kifu-core/Cargo.lock index 59b31e4..6880834 100644 --- a/kifu/kifu-core/Cargo.lock +++ b/kifu/kifu-core/Cargo.lock @@ -26,6 +26,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "grid" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0634107a3a005070dd73e27e74ecb691a94e9e5ba7829f434db7fbf73a6b5c47" +dependencies = [ + "no-std-compat", +] + [[package]] name = "hermit-abi" version = "0.2.6" @@ -39,6 +48,8 @@ dependencies = [ name = "kifu-core" version = "0.1.0" dependencies = [ + "grid", + "thiserror", "tokio", ] @@ -85,6 +96,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + [[package]] name = "num_cpus" version = "1.15.0" @@ -193,6 +210,37 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79d9531f94112cfc3e4c8f5f02cb2b58f72c97b7efd85f70203cc6d8efda5927" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.12", +] + [[package]] name = "tokio" version = "1.26.0" @@ -221,7 +269,7 @@ checksum = "d266c00fde287f55d3f1c3e96c500c362a2b8c695076ec180f27918820bc6df8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.109", ] [[package]] diff --git a/kifu/kifu-core/Cargo.toml b/kifu/kifu-core/Cargo.toml index ad2dec2..f7c8c30 100644 --- a/kifu/kifu-core/Cargo.toml +++ b/kifu/kifu-core/Cargo.toml @@ -6,4 +6,6 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tokio = { version = "1.26", features = [ "full" ] } +tokio = { version = "1.26", features = [ "full" ] } +grid = { version = "0.9" } +thiserror = { version = "1" } diff --git a/kifu/kifu-core/Makefile b/kifu/kifu-core/Makefile new file mode 100644 index 0000000..bfcdb8e --- /dev/null +++ b/kifu/kifu-core/Makefile @@ -0,0 +1,6 @@ + +test: + cargo watch -x 'nextest run' + +test-oneshot: + cargo nextest run diff --git a/kifu/kifu-core/src/api.rs b/kifu/kifu-core/src/api.rs index 983a5bc..5abd9d4 100644 --- a/kifu/kifu-core/src/api.rs +++ b/kifu/kifu-core/src/api.rs @@ -10,8 +10,8 @@ pub enum Request { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PlayStoneRequest { - pub column: u8, - pub row: u8, + pub column: usize, + pub row: usize, } #[derive(Clone, Debug)] diff --git a/kifu/kifu-core/src/board.rs b/kifu/kifu-core/src/board.rs new file mode 100644 index 0000000..55a3fc5 --- /dev/null +++ b/kifu/kifu-core/src/board.rs @@ -0,0 +1,633 @@ +use crate::{BoardError, Color, Size}; +use std::collections::HashSet; + +#[derive(Clone, Debug)] +pub struct Board { + pub size: Size, + pub groups: Vec, +} + +impl std::fmt::Display for Board { + 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 Board { + 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; + } + } + return true; + } +} + +impl Board { + pub fn new() -> Self { + Self { + size: Size { + width: 19, + height: 19, + }, + groups: Vec::new(), + } + } + + pub fn from_coordinates( + mut coordinates: impl Iterator, + ) -> Result { + coordinates.try_fold(Self::new(), |board, (coordinate, color)| { + board.place_stone(coordinate, color) + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq)] +pub struct Coordinate { + pub column: usize, + pub row: usize, +} + +impl Board { + pub fn place_stone(mut self, coordinate: Coordinate, color: Color) -> Result { + if let Some(_) = self.stone(&coordinate) { + return Err(BoardError::InvalidPosition); + } + + 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())) + .fold(HashSet::new(), |acc, set| { + acc.union(&set).cloned().collect() + }); + + friendly_group.insert(coordinate.clone()); + + self.groups + .retain(|g| g.coordinates.is_disjoint(&friendly_group)); + let friendly_group = Group { + color, + coordinates: friendly_group, + }; + self.groups.push(friendly_group.clone()); + + let adjacent_groups = self.adjacent_groups(&friendly_group); + for group in adjacent_groups { + if self.liberties(&group) == 0 { + self.remove_group(&group); + } + } + + if self.liberties(&friendly_group) == 0 { + return Err(BoardError::SelfCapture); + } + + Ok(self) + } + + pub 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> { + self.groups + .iter() + .find(|g| g.coordinates.contains(coordinate)) + } + + pub fn remove_group(&mut self, group: &Group) { + self.groups.retain(|g| g != group); + } + + pub 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 => return, + Some(adj) => { + if group.color == adj.color { + return; + } + if grps.iter().any(|g| g.coordinates.contains(&coord)) { + return; + } + grps.push(adj.clone()); + } + }); + + grps + } + + pub fn group_halo(&self, group: &Group) -> HashSet { + group + .coordinates + .iter() + .map(|c| self.adjacencies(c)) + .flatten() + .collect::>() + } + + pub fn liberties(&self, group: &Group) -> usize { + self.group_halo(group) + .into_iter() + .filter(|c| self.stone(&c) == None) + .count() + } + + pub 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() + } + + pub 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(Board)) { + let board = Board::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 = Board::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 = Board::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: Board| { + 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), + ), + ]; + + println!("{}", board); + 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 = Board::from_coordinates( + vec![(Coordinate { column: 7, row: 9 }, Color::White)].into_iter(), + ) + .unwrap(); + let b2 = Board::from_coordinates( + vec![(Coordinate { column: 7, row: 9 }, Color::White)].into_iter(), + ) + .unwrap(); + + assert_eq!(b1, b2); + } + + { + let b1 = Board::from_coordinates( + vec![ + (Coordinate { column: 7, row: 9 }, Color::White), + (Coordinate { column: 8, row: 10 }, Color::White), + ] + .into_iter(), + ) + .unwrap(); + let b2 = Board::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 = Board::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); + } +} diff --git a/kifu/kifu-core/src/lib.rs b/kifu/kifu-core/src/lib.rs index 9c22bc8..8b598bb 100644 --- a/kifu/kifu-core/src/lib.rs +++ b/kifu/kifu-core/src/lib.rs @@ -2,5 +2,8 @@ mod api; pub use api::{CoreApp, Request, Response}; mod types; -pub use types::{Color, Size}; +pub use types::{BoardError, Color, Size}; pub mod ui; + +mod board; +pub use board::*; diff --git a/kifu/kifu-core/src/types.rs b/kifu/kifu-core/src/types.rs index e9c9f32..912632f 100644 --- a/kifu/kifu-core/src/types.rs +++ b/kifu/kifu-core/src/types.rs @@ -1,16 +1,30 @@ -use crate::api::PlayStoneRequest; +use crate::{ + api::PlayStoneRequest, + board::{Board, Coordinate}, +}; use std::time::Duration; +use thiserror::Error; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Error)] +pub enum BoardError { + #[error("Position is invalid")] + InvalidPosition, + #[error("Self-capture is forbidden")] + SelfCapture, + #[error("Ko")] + Ko, +} + +#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq)] pub enum Color { Black, White, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Size { - pub width: u8, - pub height: u8, + pub width: usize, + pub height: usize, } impl Default for Size { @@ -36,7 +50,10 @@ impl AppState { pub fn place_stone(&mut self, req: PlayStoneRequest) { match self.game { Some(ref mut game) => { - game.place_stone(req.column, req.row); + game.place_stone(Coordinate { + column: req.column, + row: req.row, + }); } None => {} } @@ -74,6 +91,8 @@ pub struct Player { pub struct GameState { pub board: Board, + pub past_positions: Vec, + pub conversation: Vec, pub current_player: Color, @@ -88,6 +107,7 @@ impl GameState { fn new() -> GameState { GameState { board: Board::new(), + past_positions: vec![], conversation: vec![], current_player: Color::Black, white_player: Player { @@ -103,46 +123,78 @@ impl GameState { } } - fn place_stone(&mut self, column: u8, row: u8) { - self.board.place_stone(column, row, self.current_player); + fn place_stone(&mut self, coordinate: Coordinate) -> Result<(), BoardError> { + let board = self.board.clone(); + let new_board = board.place_stone(coordinate, self.current_player)?; + + if self.past_positions.contains(&new_board) { + return Err(BoardError::Ko); + } + + self.past_positions.push(self.board.clone()); + self.board = new_board; match self.current_player { Color::White => self.current_player = Color::Black, Color::Black => self.current_player = Color::White, - } + }; + Ok(()) } } -pub struct Board { - pub size: Size, - pub spaces: Vec>, -} +#[cfg(test)] +mod test { + use super::*; -impl Board { - fn new() -> Self { - let mut spaces = Vec::new(); - for _ in 0..19 * 19 { - spaces.push(None); - } - Self { - size: Size { - width: 19, - height: 19, - }, - spaces, - } + #[test] + fn current_player_changes_after_move() { + let mut state = GameState::new(); + assert_eq!(state.current_player, Color::Black); + state.place_stone(Coordinate { column: 9, row: 9 }).unwrap(); + assert_eq!(state.current_player, Color::White); } - pub fn place_stone(&mut self, column: u8, row: u8, stone: Color) { - let addr = self.addr(column, row); - self.spaces[addr] = Some(stone); + #[test] + fn current_player_remains_the_same_after_self_capture() { + let mut state = GameState::new(); + state.board = Board::from_coordinates( + vec![ + (Coordinate { column: 17, row: 0 }, Color::White), + (Coordinate { column: 17, row: 1 }, Color::White), + (Coordinate { column: 18, row: 1 }, Color::White), + ] + .into_iter(), + ) + .unwrap(); + state.current_player = Color::Black; + + assert_eq!( + state.place_stone(Coordinate { column: 18, row: 0 }), + Err(BoardError::SelfCapture) + ); + assert_eq!(state.current_player, Color::Black); } - pub fn stone(&self, column: u8, row: u8) -> Option { - let addr = self.addr(column, row); - self.spaces[addr] - } + #[test] + fn ko_rules_are_enforced() { + let mut state = GameState::new(); + state.board = Board::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(); - fn addr(&self, column: u8, row: u8) -> usize { - ((row as usize) * (self.size.width as usize) + (column as usize)) as usize + state.place_stone(Coordinate { column: 8, row: 9 }).unwrap(); + assert_eq!( + state.place_stone(Coordinate { column: 9, row: 9 }), + Err(BoardError::Ko) + ); } } diff --git a/kifu/kifu-core/src/ui/playing_field.rs b/kifu/kifu-core/src/ui/playing_field.rs index cdc9c12..abd5456 100644 --- a/kifu/kifu-core/src/ui/playing_field.rs +++ b/kifu/kifu-core/src/ui/playing_field.rs @@ -1,5 +1,7 @@ -use crate::types::Color; -use crate::{types::GameState, ui::types}; +use crate::{ + types::{Color, GameState}, + ui::types, +}; #[derive(Clone, Debug)] pub struct PlayingFieldView { diff --git a/kifu/kifu-core/src/ui/types.rs b/kifu/kifu-core/src/ui/types.rs index 39e2904..027f8db 100644 --- a/kifu/kifu-core/src/ui/types.rs +++ b/kifu/kifu-core/src/ui/types.rs @@ -1,7 +1,7 @@ use crate::types::{Color, Size}; use crate::{ api::{PlayStoneRequest, Request}, - types::Board, + Board, Coordinate, }; #[derive(Clone, Copy, Debug, PartialEq)] @@ -14,12 +14,14 @@ pub struct Jitter { pub struct StoneElement { pub color: Color, pub jitter: Jitter, + pub liberties: Option, } impl StoneElement { - pub fn new(color: Color) -> Self { + pub fn new(color: Color, liberties: Option) -> Self { Self { color, + liberties, jitter: Jitter { x: 0, y: 0 }, } } @@ -71,9 +73,10 @@ impl From<&Board> for BoardElement { let spaces: Vec = (0..board.size.height) .map(|row| { (0..board.size.width) - .map(|column| match board.stone(column, row) { + .map(|column| match board.stone(&Coordinate { column, row }) { Some(color) => IntersectionElement::Filled(StoneElement { jitter: Jitter { x: 0, y: 0 }, + liberties: None, color, }), None => IntersectionElement::Empty(Request::PlayStoneRequest( diff --git a/kifu/kifu-gtk/Cargo.lock b/kifu/kifu-gtk/Cargo.lock index 7e5c456..c7faf28 100644 --- a/kifu/kifu-gtk/Cargo.lock +++ b/kifu/kifu-gtk/Cargo.lock @@ -488,6 +488,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "grid" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0634107a3a005070dd73e27e74ecb691a94e9e5ba7829f434db7fbf73a6b5c47" +dependencies = [ + "no-std-compat", +] + [[package]] name = "gsk4" version = "0.6.3" @@ -657,6 +666,8 @@ dependencies = [ name = "kifu-core" version = "0.1.0" dependencies = [ + "grid", + "thiserror", "tokio", ] @@ -751,6 +762,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + [[package]] name = "num-integer" version = "0.1.45" diff --git a/kifu/kifu-gtk/Makefile b/kifu/kifu-gtk/Makefile new file mode 100644 index 0000000..2297add --- /dev/null +++ b/kifu/kifu-gtk/Makefile @@ -0,0 +1,5 @@ +release: + cargo build --release + +dev: + cargo watch -x 'run --bin kifu-gtk' diff --git a/kifu/kifu-gtk/src/ui/board.rs b/kifu/kifu-gtk/src/ui/board.rs index 7b442f5..2940066 100644 --- a/kifu/kifu-gtk/src/ui/board.rs +++ b/kifu/kifu-gtk/src/ui/board.rs @@ -141,7 +141,7 @@ impl ObjectImpl for BoardPrivate { (0..19).for_each(|row| { match board.stone(row, col) { IntersectionElement::Filled(stone) => { - pen.stone(&context, row, col, stone.color); + pen.stone(&context, row, col, stone.color, stone.liberties); } _ => {} }; @@ -270,12 +270,27 @@ impl Pen { let _ = context.fill(); } - fn stone(&self, context: &cairo::Context, row: u8, col: u8, color: Color) { + fn stone( + &self, + context: &cairo::Context, + row: u8, + col: u8, + color: Color, + liberties: Option, + ) { match color { Color::White => context.set_source_rgb(0.9, 0.9, 0.9), Color::Black => context.set_source_rgb(0.0, 0.0, 0.0), }; self.draw_stone(context, row, col); + + if let Some(liberties) = liberties { + let stone_location = self.stone_location(row, col); + context.set_source_rgb(1., 0., 1.); + context.set_font_size(32.); + context.move_to(stone_location.0 - 10., stone_location.1 + 10.); + let _ = context.show_text(&format!("{}", liberties)); + } } fn ghost_stone(&self, context: &cairo::Context, row: u8, col: u8, color: Color) { @@ -288,13 +303,15 @@ impl Pen { fn draw_stone(&self, context: &cairo::Context, row: u8, col: u8) { let radius = self.hspace_between / 2. - 2.; - context.arc( - self.x_offset + (col as f64) * self.hspace_between, - self.y_offset + (row as f64) * self.vspace_between, - radius, - 0.0, - 2.0 * std::f64::consts::PI, - ); + let (x_loc, y_loc) = self.stone_location(row, col); + context.arc(x_loc, y_loc, radius, 0.0, 2.0 * std::f64::consts::PI); let _ = context.fill(); } + + fn stone_location(&self, row: u8, col: u8) -> (f64, f64) { + ( + self.x_offset + (col as f64) * self.hspace_between, + self.y_offset + (row as f64) * self.vspace_between, + ) + } }