My attempt at writing a go (the board game) engine, in Rust
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add capture detection to Board

- Board::place now returns Vec<Coord> of captured stones
- After placement, checks all orthogonal neighbors for opponent groups with 0 liberties
- Removes captured stones and returns their coordinates
- Updated existing test to verify capture behavior instead of 0-liberties query

+140 -6
+130 -2
src/board.rs
··· 25 25 26 26 /// Place a stone at the given coordinate. 27 27 /// Returns `Err(GoError::Occupied)` if the point is not empty. 28 - pub fn place(&mut self, coord: Coord<W, H>, color: Color) -> Result<(), GoError> { 28 + /// Returns the list of captured opponent stones. 29 + pub fn place(&mut self, coord: Coord<W, H>, color: Color) -> Result<Vec<Coord<W, H>>, GoError> { 29 30 let idx = Self::index_of(coord); 30 31 if self.stones[idx].is_some() { 31 32 return Err(GoError::Occupied); 32 33 } 33 34 self.stones[idx] = Some(color); 34 - Ok(()) 35 + 36 + let mut captured = Vec::new(); 37 + let opponent = color.opposite(); 38 + 39 + // Check all four orthogonal neighbors for opponent groups to capture. 40 + let x = coord.x() as i8; 41 + let y = coord.y() as i8; 42 + let neighbors = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)]; 43 + 44 + for (nx, ny) in neighbors { 45 + let nx = nx as u8; 46 + let ny = ny as u8; 47 + let Ok(neighbor) = Coord::<W, H>::try_from((nx, ny)) else { 48 + continue; 49 + }; 50 + // check if neighbor is part of a group that has now 0 liberties 51 + if self.get(neighbor) == Some(opponent) 52 + && let Some(group) = self.group_at(neighbor) 53 + && group.liberty_count() == 0 54 + { 55 + for stone in group.stones() { 56 + self.remove(*stone); 57 + captured.push(*stone); 58 + } 59 + } 60 + } 61 + 62 + Ok(captured) 35 63 } 36 64 37 65 /// Remove a stone from the given coordinate. ··· 185 213 let board = Board::<5, 5>::default(); 186 214 let coord: Coord<5, 5> = (2, 2).try_into().unwrap(); 187 215 assert!(board.is_empty(coord)); 216 + } 217 + 218 + #[test] 219 + fn capture_single_stone() { 220 + // A stone in atari is captured when its last liberty is filled. 221 + let mut board = Board::<3, 3>::new(); 222 + let c = |x, y| Coord::<3, 3>::try_from((x, y)).unwrap(); 223 + 224 + board.place(c(0, 1), Color::Black).unwrap(); 225 + board.place(c(1, 0), Color::Black).unwrap(); 226 + board.place(c(1, 2), Color::Black).unwrap(); 227 + board.place(c(2, 1), Color::White).unwrap(); 228 + 229 + // White at (2,1) has liberties at (1,1) and (2,0), (2,2) 230 + // Surround it completely 231 + board.place(c(2, 0), Color::Black).unwrap(); 232 + board.place(c(2, 2), Color::Black).unwrap(); 233 + 234 + // Now capture by filling (1,1) 235 + let captured = board.place(c(1, 1), Color::Black).unwrap(); 236 + assert_eq!(captured.len(), 1); 237 + assert!(captured.contains(&c(2, 1))); 238 + assert!(board.is_empty(c(2, 1))); 239 + } 240 + 241 + #[test] 242 + fn capture_multi_stone_group() { 243 + // A connected group with no liberties is fully captured. 244 + let mut board = Board::<3, 3>::new(); 245 + let c = |x, y| Coord::<3, 3>::try_from((x, y)).unwrap(); 246 + 247 + // Place a white group of 2 stones in the corner 248 + board.place(c(0, 0), Color::White).unwrap(); 249 + board.place(c(1, 0), Color::White).unwrap(); 250 + 251 + // Surround them with black, leaving (1,1) as last liberty 252 + board.place(c(0, 1), Color::Black).unwrap(); 253 + board.place(c(2, 0), Color::Black).unwrap(); 254 + 255 + // Capture by filling the last liberty 256 + let captured = board.place(c(1, 1), Color::Black).unwrap(); 257 + assert_eq!(captured.len(), 2); 258 + assert!(captured.contains(&c(0, 0))); 259 + assert!(captured.contains(&c(1, 0))); 260 + assert!(board.is_empty(c(0, 0))); 261 + assert!(board.is_empty(c(1, 0))); 262 + } 263 + 264 + #[test] 265 + fn no_capture_when_liberties_remain() { 266 + // Placing next to a group that still has liberties captures nothing. 267 + let mut board = Board::<5, 5>::new(); 268 + let c = |x, y| Coord::<5, 5>::try_from((x, y)).unwrap(); 269 + 270 + board.place(c(2, 2), Color::White).unwrap(); 271 + board.place(c(1, 2), Color::Black).unwrap(); 272 + board.place(c(3, 2), Color::Black).unwrap(); 273 + board.place(c(2, 1), Color::Black).unwrap(); 274 + 275 + // White still has liberty at (2,3) 276 + let captured = board.place(c(1, 1), Color::Black).unwrap(); 277 + assert!(captured.is_empty()); 278 + assert_eq!(board.get(c(2, 2)), Some(Color::White)); 279 + } 280 + 281 + #[test] 282 + fn capture_multiple_groups_at_once() { 283 + // A single placement can capture multiple separate opponent groups. 284 + let mut board = Board::<3, 3>::new(); 285 + let c = |x, y| Coord::<3, 3>::try_from((x, y)).unwrap(); 286 + 287 + // Two separate white stones on the top row, each with only (1,2) as liberty 288 + board.place(c(0, 2), Color::White).unwrap(); 289 + board.place(c(0, 1), Color::Black).unwrap(); 290 + board.place(c(2, 2), Color::White).unwrap(); 291 + board.place(c(2, 1), Color::Black).unwrap(); 292 + 293 + // Playing at (1,2) removes the last liberty from both white stones 294 + let captured = board.place(c(1, 2), Color::Black).unwrap(); 295 + assert_eq!(captured.len(), 2); 296 + assert!(captured.contains(&c(0, 2))); 297 + assert!(captured.contains(&c(2, 2))); 298 + assert!(board.is_empty(c(0, 2))); 299 + assert!(board.is_empty(c(2, 2))); 300 + } 301 + 302 + #[test] 303 + fn capture_corner_stone() { 304 + // A corner stone can be captured with just 2 surrounding stones. 305 + let mut board = Board::<3, 3>::new(); 306 + let c = |x, y| Coord::<3, 3>::try_from((x, y)).unwrap(); 307 + 308 + board.place(c(0, 0), Color::White).unwrap(); 309 + board.place(c(1, 0), Color::Black).unwrap(); 310 + 311 + // Filling the last liberty captures the corner stone 312 + let captured = board.place(c(0, 1), Color::Black).unwrap(); 313 + assert_eq!(captured.len(), 1); 314 + assert!(captured.contains(&c(0, 0))); 315 + assert!(board.is_empty(c(0, 0))); 188 316 } 189 317 190 318 mod proptests {
+10 -4
src/group.rs
··· 142 142 } 143 143 144 144 #[test] 145 - fn surrounded_single_stone_no_liberties() { 146 - // A stone surrounded on all 4 sides has 0 liberties. 145 + fn surrounded_single_stone_is_captured() { 146 + // A stone surrounded on all 4 sides is captured and removed. 147 147 let mut board = Board::<3, 3>::new(); 148 148 board.place(c3(1, 1), Color::Black).unwrap(); 149 149 board.place(c3(0, 1), Color::White).unwrap(); 150 150 board.place(c3(2, 1), Color::White).unwrap(); 151 151 board.place(c3(1, 0), Color::White).unwrap(); 152 - board.place(c3(1, 2), Color::White).unwrap(); 153 152 153 + // Black still has one liberty at (1,2) 154 154 let group = board.group_at(c3(1, 1)).unwrap(); 155 - assert_eq!(group.liberty_count(), 0); 155 + assert_eq!(group.liberty_count(), 1); 156 + 157 + // Filling the last liberty captures the black stone 158 + let captured = board.place(c3(1, 2), Color::White).unwrap(); 159 + assert_eq!(captured.len(), 1); 160 + assert!(captured.contains(&c3(1, 1))); 161 + assert_eq!(board.group_at(c3(1, 1)), None); 156 162 } 157 163 158 164 #[test]