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 play_as with NotYourTurn validation

- play_as(color, coord) validates the requested color matches to_move
- play(coord) continues to always play for the current player
- Add unit tests for wrong/right color and Default impl
- Add proptests with non_adjacent_seq strategy for long sequences

+191 -1
+191 -1
src/game.rs
··· 37 37 self.prev_board.as_ref() 38 38 } 39 39 40 - /// Play a stone at the given coordinate for the current player. 40 + /// Play a stone at the given coordinate for the given color. 41 41 /// Validates turn order, occupancy, suicide, and simple ko. 42 + pub fn play_as(&mut self, color: Color, coord: Coord<W, H>) -> Result<(), GoError> { 43 + if color != self.to_move { 44 + return Err(GoError::NotYourTurn); 45 + } 46 + 47 + self.play(coord) 48 + } 49 + 50 + /// Play a stone at the given coordinate for the current player. 51 + /// Validates occupancy, suicide, and simple ko. 42 52 pub fn play(&mut self, coord: Coord<W, H>) -> Result<(), GoError> { 43 53 // Check occupancy 44 54 if !self.board.is_empty(coord) { ··· 101 111 #[cfg(test)] 102 112 mod tests { 103 113 use super::*; 114 + use proptest::prelude::*; 104 115 105 116 fn c5(x: u8, y: u8) -> Coord<5, 5> { 106 117 Coord::try_from((x, y)).unwrap() ··· 230 241 game.pass(); // W pass saves board after B's move 231 242 assert!(game.prev_board().is_some()); 232 243 assert!(!game.prev_board().unwrap().is_empty(c3(0, 0))); 244 + } 245 + 246 + #[test] 247 + fn play_as_wrong_color_is_rejected() { 248 + let mut game = Game::<5, 5>::new(); 249 + // White tries to play black's turn 250 + assert_eq!( 251 + game.play_as(Color::White, c5(2, 2)), 252 + Err(GoError::NotYourTurn) 253 + ); 254 + // Play a valid move to switch to White's turn 255 + game.play(c5(2, 2)).unwrap(); 256 + // Black tries to play white's turn 257 + assert_eq!( 258 + game.play_as(Color::Black, c5(3, 3)), 259 + Err(GoError::NotYourTurn) 260 + ); 261 + } 262 + 263 + #[test] 264 + fn play_as_right_color_succeeds() { 265 + let mut game = Game::<5, 5>::new(); 266 + game.play_as(Color::Black, c5(2, 2)).unwrap(); 267 + assert_eq!(game.to_move(), Color::White); 268 + game.play_as(Color::White, c5(3, 3)).unwrap(); 269 + assert_eq!(game.to_move(), Color::Black); 270 + } 271 + 272 + #[test] 273 + fn play_after_play_as_still_checks_turn() { 274 + let mut game = Game::<5, 5>::new(); 275 + game.play(c5(2, 2)).unwrap(); // B 276 + // play() always uses to_move, so this is white's turn 277 + game.play(c5(3, 3)).unwrap(); // W 278 + assert_eq!(game.to_move(), Color::Black); 233 279 } 234 280 235 281 #[test] ··· 244 290 } 245 291 246 292 #[test] 293 + fn game_default_matches_new() { 294 + let via_new = Game::<5, 5>::new(); 295 + let via_default: Game<5, 5> = Default::default(); 296 + assert_eq!(via_new.board(), via_default.board()); 297 + assert_eq!(via_new.to_move(), via_default.to_move()); 298 + assert_eq!(via_new.captures(), via_default.captures()); 299 + assert_eq!(via_new.prev_board(), via_default.prev_board()); 300 + } 301 + 302 + #[test] 247 303 fn play_updates_board() { 248 304 let mut game = Game::<5, 5>::new(); 249 305 game.play(c5(2, 2)).unwrap(); 250 306 assert_eq!(game.board().get(c5(2, 2)), Some(Color::Black)); 307 + } 308 + 309 + mod proptests { 310 + use super::*; 311 + 312 + fn coord_5x5() -> impl Strategy<Value = Coord<5, 5>> { 313 + (0u8..5, 0u8..5).prop_map(|(x, y)| Coord::try_from((x, y)).unwrap()) 314 + } 315 + 316 + /// Generate a sequence of non-adjacent coords on a 5x5 board. 317 + /// Places stones on y=0 (black's row) and y=4 (white's row) with 318 + /// at least one empty column between any two stones, avoiding all 319 + /// captures and suicide. 320 + fn non_adjacent_seq() -> impl Strategy<Value = Vec<Coord<5, 5>>> { 321 + // Pick up to 5 x-positions for row 0 and up to 5 for row 4, 322 + // ensuring no two are adjacent. 323 + let row0 = prop::collection::vec(0u8..5, 0..=3).prop_filter("adjacent in row0", |xs| { 324 + let mut xs = xs.clone(); 325 + xs.sort_unstable(); 326 + xs.windows(2).all(|w| (w[1] - w[0]) > 1) 327 + }); 328 + let row4 = prop::collection::vec(0u8..5, 0..=3).prop_filter("adjacent in row4", |xs| { 329 + let mut xs = xs.clone(); 330 + xs.sort_unstable(); 331 + xs.windows(2).all(|w| (w[1] - w[0]) > 1) 332 + }); 333 + 334 + (row0, row4).prop_map(|(r0, r4)| { 335 + let mut seq = Vec::new(); 336 + let mut i0 = 0usize; 337 + let mut i4 = 0usize; 338 + let mut is_black = true; 339 + 340 + // Interleave: black plays from row0, white from row4 341 + while i0 < r0.len() || i4 < r4.len() { 342 + if is_black && i0 < r0.len() { 343 + seq.push(Coord::<5, 5>::try_from((r0[i0], 0)).unwrap()); 344 + i0 += 1; 345 + } else if !is_black && i4 < r4.len() { 346 + seq.push(Coord::<5, 5>::try_from((r4[i4], 4)).unwrap()); 347 + i4 += 1; 348 + } 349 + is_black = !is_black; 350 + } 351 + seq 352 + }) 353 + } 354 + 355 + proptest! { 356 + #[test] 357 + fn play_as_wrong_color_fails(coord in coord_5x5()) { 358 + // On a new game, to_move is Black. play_as(White, ...) must fail. 359 + let mut game = Game::<5, 5>::new(); 360 + prop_assert_eq!( 361 + game.play_as(Color::White, coord), 362 + Err(GoError::NotYourTurn) 363 + ); 364 + } 365 + 366 + #[test] 367 + fn play_as_right_color_switches_turn(coord in coord_5x5()) { 368 + // play_as(Black, ...) on a new game should succeed and switch to White. 369 + let mut game = Game::<5, 5>::new(); 370 + prop_assert!(game.play_as(Color::Black, coord).is_ok()); 371 + prop_assert_eq!(game.to_move(), Color::White); 372 + // Now play_as(Black, ...) should fail again 373 + prop_assert_eq!( 374 + game.play_as(Color::Black, coord), 375 + Err(GoError::NotYourTurn) 376 + ); 377 + } 378 + 379 + #[test] 380 + fn turn_alternation_after_two_plays(c1 in coord_5x5(), c2 in coord_5x5()) { 381 + // After two valid plays (on distinct empty coords), turn returns to Black. 382 + prop_assume!(c1 != c2); 383 + let mut game = Game::<5, 5>::new(); 384 + prop_assert!(game.play(c1).is_ok()); 385 + prop_assert!(game.play(c2).is_ok()); 386 + prop_assert_eq!(game.to_move(), Color::Black); 387 + } 388 + 389 + #[test] 390 + fn long_sequence_maintains_turn_order(moves in non_adjacent_seq()) { 391 + // Play a long sequence of non-interacting moves and verify 392 + // turn order is always enforced. 393 + prop_assume!(!moves.is_empty()); 394 + let mut game = Game::<5, 5>::new(); 395 + let mut expected = Color::Black; 396 + 397 + for (i, &coord) in moves.iter().enumerate() { 398 + // play_as with the wrong color must always fail 399 + let wrong = expected.opposite(); 400 + prop_assert_eq!( 401 + game.play_as(wrong, coord), 402 + Err(GoError::NotYourTurn), 403 + "move {}: {:?} should have failed for wrong color {:?}", 404 + i, coord, wrong 405 + ); 406 + 407 + // play_as with the right color must succeed 408 + prop_assert!( 409 + game.play_as(expected, coord).is_ok(), 410 + "move {}: {:?} failed for expected color {:?}", 411 + i, coord, expected 412 + ); 413 + 414 + expected = expected.opposite(); 415 + prop_assert_eq!(game.to_move(), expected); 416 + } 417 + } 418 + 419 + #[test] 420 + fn long_sequence_play_method_alternates(moves in non_adjacent_seq()) { 421 + // Using play() (no explicit color) should also alternate correctly. 422 + prop_assume!(moves.len() >= 2); 423 + let mut game = Game::<5, 5>::new(); 424 + 425 + for (i, &coord) in moves.iter().enumerate() { 426 + prop_assert!( 427 + game.play(coord).is_ok(), 428 + "move {} at {:?} failed unexpectedly", 429 + i, coord 430 + ); 431 + } 432 + 433 + // After an even number of moves, it's Black's turn again 434 + if moves.len() % 2 == 0 { 435 + prop_assert_eq!(game.to_move(), Color::Black); 436 + } else { 437 + prop_assert_eq!(game.to_move(), Color::White); 438 + } 439 + } 440 + } 251 441 } 252 442 }