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 Color enum and Coord<W,H> type

Jakob Sachs 09c08598

+415
+1
.gitignore
··· 1 + /target
+11
AGENTS.md
··· 1 + - when asked to implement a new feature, you must always follow the same steps: 2 + 1. implement/scaffold the feature to a minimal state where it compiles 3 + 2. write tests for the expected behavour against this scaffold (all will be red) 4 + 3. fix the implementation so that all tests are green 5 + (this is the basic tdd red->green pattern) 6 + 7 + - when asked to fix a bug, always start by trying to replicate it through a target test-case, 8 + then fix the implementation, and finally validate the target-test-case is now green 9 + 10 + - for writing tests, always try to think about the intended behaviour, not what the implementation is doing 11 + - targetted tests are fine, but always consider first if this could be covered better by a property-test or fuzzer
+65
Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "go-engine" 7 + version = "0.1.0" 8 + dependencies = [ 9 + "thiserror", 10 + ] 11 + 12 + [[package]] 13 + name = "proc-macro2" 14 + version = "1.0.106" 15 + source = "registry+https://github.com/rust-lang/crates.io-index" 16 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 17 + dependencies = [ 18 + "unicode-ident", 19 + ] 20 + 21 + [[package]] 22 + name = "quote" 23 + version = "1.0.45" 24 + source = "registry+https://github.com/rust-lang/crates.io-index" 25 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 26 + dependencies = [ 27 + "proc-macro2", 28 + ] 29 + 30 + [[package]] 31 + name = "syn" 32 + version = "2.0.117" 33 + source = "registry+https://github.com/rust-lang/crates.io-index" 34 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 35 + dependencies = [ 36 + "proc-macro2", 37 + "quote", 38 + "unicode-ident", 39 + ] 40 + 41 + [[package]] 42 + name = "thiserror" 43 + version = "2.0.18" 44 + source = "registry+https://github.com/rust-lang/crates.io-index" 45 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 46 + dependencies = [ 47 + "thiserror-impl", 48 + ] 49 + 50 + [[package]] 51 + name = "thiserror-impl" 52 + version = "2.0.18" 53 + source = "registry+https://github.com/rust-lang/crates.io-index" 54 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 55 + dependencies = [ 56 + "proc-macro2", 57 + "quote", 58 + "syn", 59 + ] 60 + 61 + [[package]] 62 + name = "unicode-ident" 63 + version = "1.0.24" 64 + source = "registry+https://github.com/rust-lang/crates.io-index" 65 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+7
Cargo.toml
··· 1 + [package] 2 + name = "go-engine" 3 + version = "0.1.0" 4 + edition = "2024" 5 + 6 + [dependencies] 7 + thiserror = "2.0.18"
+57
spec.md
··· 1 + # go-engine spec 2 + 3 + ## rules 4 + - japanese rules 5 + - simple ko only (TODO: superko, other rulesets) 6 + - normal geometry, variable board size at compile time 7 + 8 + ## types 9 + 10 + ``` 11 + Board<const W: usize, const H: usize> 12 + - stones: [Option<Color>; W * H] 13 + - no knowledge of turns, captures, or history 14 + 15 + Coord<const W: usize, const H: usize> 16 + - x, y as u8 (or smaller int if useful) 17 + - compile-time bounds guarantee 18 + - TryFrom<(u8, u8)> for runtime input 19 + - TryFrom<&str> for GTF/SGF notation 20 + 21 + Game<const W: usize, const H: usize> 22 + - board: Board<W, H> 23 + - to_move: Color 24 + - captures: (u16, u16) // black, white 25 + - prev_board: Option<Board<W, H>> // for simple ko 26 + 27 + Color: Black | White 28 + ``` 29 + 30 + ## errors (thiserror) 31 + - OutOfBounds 32 + - Occupied 33 + - Suicide 34 + - Ko 35 + - NotYourTurn (if checked at Game level) 36 + - InvalidCoordinateFormat 37 + 38 + ## invariants 39 + - Board checks occupancy and bounds on every stone placement 40 + - Game checks suicide, ko, and turn order on play() 41 + - Board never knows captures; Game tracks them 42 + 43 + ## scoring 44 + - territory scoring (japanese) 45 + - manual dead stone marking required 46 + - implemented as method on Game only 47 + - TODO: area scoring, automatic life/death 48 + 49 + ## open questions / TODO 50 + - superko (positional / situational) 51 + - other rulesets (chinese, aga) 52 + - automatic life/death detection 53 + - komi / handicap 54 + - ko fight counting 55 + - time control 56 + - sgf import/export 57 + - gtp protocol
+25
src/color.rs
··· 1 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 2 + pub enum Color { 3 + Black, 4 + White, 5 + } 6 + 7 + impl Color { 8 + pub const fn opposite(self) -> Self { 9 + match self { 10 + Color::Black => Color::White, 11 + Color::White => Color::Black, 12 + } 13 + } 14 + } 15 + 16 + #[cfg(test)] 17 + mod tests { 18 + use super::*; 19 + 20 + #[test] 21 + fn opposite() { 22 + assert_eq!(Color::Black.opposite(), Color::White); 23 + assert_eq!(Color::White.opposite(), Color::Black); 24 + } 25 + }
+225
src/coord.rs
··· 1 + use crate::error::GoError; 2 + 3 + /// A coordinate on a board of width W and height H. 4 + /// (0, 0) is the bottom-left corner. 5 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 6 + pub struct Coord<const W: usize, const H: usize> { 7 + x: u8, 8 + y: u8, 9 + } 10 + 11 + impl<const W: usize, const H: usize> Coord<W, H> { 12 + pub const fn x(self) -> u8 { 13 + self.x 14 + } 15 + 16 + pub const fn y(self) -> u8 { 17 + self.y 18 + } 19 + 20 + /// Parse a GTP column letter into a 0-based x coordinate. 21 + /// GTP skips 'I', so the sequence is A-H, J-T, ... 22 + fn gtp_column_to_x(col: char) -> Option<u8> { 23 + let col = col.to_ascii_uppercase(); 24 + if !col.is_ascii_uppercase() { 25 + return None; 26 + } 27 + let val = col as u8 - b'A'; 28 + if col >= 'I' { 29 + Some(val - 1) 30 + } else { 31 + Some(val) 32 + } 33 + } 34 + 35 + /// Convert a 0-based x coordinate to a GTP column letter. 36 + fn x_to_gtp_column(x: u8) -> Option<char> { 37 + if x >= 25 { 38 + return None; 39 + } 40 + let byte = if x >= 8 { x + 1 + b'A' } else { x + b'A' }; 41 + Some(byte as char) 42 + } 43 + 44 + pub fn to_gtp_string(self) -> Option<String> { 45 + let col = Self::x_to_gtp_column(self.x)?; 46 + let row = self.y + 1; 47 + Some(format!("{}{}", col, row)) 48 + } 49 + 50 + pub fn to_sgf_string(self) -> String { 51 + let x_char = (b'a' + self.x) as char; 52 + let y_char = (b'a' + self.y) as char; 53 + format!("{}{}", x_char, y_char) 54 + } 55 + } 56 + 57 + impl<const W: usize, const H: usize> TryFrom<(u8, u8)> for Coord<W, H> { 58 + type Error = GoError; 59 + 60 + fn try_from((x, y): (u8, u8)) -> Result<Self, Self::Error> { 61 + if x >= W as u8 || y >= H as u8 { 62 + return Err(GoError::OutOfBounds); 63 + } 64 + Ok(Coord { x, y }) 65 + } 66 + } 67 + 68 + impl<const W: usize, const H: usize> TryFrom<&str> for Coord<W, H> { 69 + type Error = GoError; 70 + 71 + fn try_from(s: &str) -> Result<Self, Self::Error> { 72 + // Empty or too short 73 + if s.len() < 2 { 74 + return Err(GoError::InvalidCoordinateFormat); 75 + } 76 + 77 + let bytes = s.as_bytes(); 78 + 79 + // SGF: two lowercase letters, e.g. "aa", "ss" 80 + if bytes.len() == 2 && bytes[0].is_ascii_lowercase() && bytes[1].is_ascii_lowercase() { 81 + let x = bytes[0] - b'a'; 82 + let y = bytes[1] - b'a'; 83 + if x >= W as u8 || y >= H as u8 { 84 + return Err(GoError::OutOfBounds); 85 + } 86 + return Ok(Coord { x, y }); 87 + } 88 + 89 + // GTP: letter(s) + number(s), e.g. "A1", "T19" 90 + // Find split between letters and numbers 91 + let letter_end = bytes 92 + .iter() 93 + .position(|b| !b.is_ascii_alphabetic()) 94 + .unwrap_or(bytes.len()); 95 + 96 + if letter_end == 0 || letter_end >= bytes.len() { 97 + return Err(GoError::InvalidCoordinateFormat); 98 + } 99 + 100 + let col_chars = &s[..letter_end]; 101 + let row_str = &s[letter_end..]; 102 + 103 + if col_chars.len() != 1 { 104 + return Err(GoError::InvalidCoordinateFormat); 105 + } 106 + 107 + let x = Self::gtp_column_to_x(col_chars.chars().next().unwrap()) 108 + .ok_or(GoError::InvalidCoordinateFormat)?; 109 + 110 + let y = row_str 111 + .parse::<u8>() 112 + .map_err(|_| GoError::InvalidCoordinateFormat)?; 113 + 114 + if y == 0 || y > H as u8 { 115 + return Err(GoError::OutOfBounds); 116 + } 117 + 118 + let y = y - 1; // convert 1-based to 0-based 119 + 120 + if x >= W as u8 { 121 + return Err(GoError::OutOfBounds); 122 + } 123 + 124 + Ok(Coord { x, y }) 125 + } 126 + } 127 + 128 + #[cfg(test)] 129 + mod tests { 130 + use super::*; 131 + 132 + #[test] 133 + fn from_tuple_valid() { 134 + let c: Coord<19, 19> = (0, 0).try_into().unwrap(); 135 + assert_eq!(c.x(), 0); 136 + assert_eq!(c.y(), 0); 137 + 138 + let c: Coord<19, 19> = (18, 18).try_into().unwrap(); 139 + assert_eq!(c.x(), 18); 140 + assert_eq!(c.y(), 18); 141 + } 142 + 143 + #[test] 144 + fn from_tuple_out_of_bounds() { 145 + let result: Result<Coord<19, 19>, _> = (19, 0).try_into(); 146 + assert_eq!(result, Err(GoError::OutOfBounds)); 147 + 148 + let result: Result<Coord<19, 19>, _> = (0, 19).try_into(); 149 + assert_eq!(result, Err(GoError::OutOfBounds)); 150 + } 151 + 152 + #[test] 153 + fn from_gtp_valid() { 154 + let c: Coord<19, 19> = "A1".try_into().unwrap(); 155 + assert_eq!(c.x(), 0); 156 + assert_eq!(c.y(), 0); 157 + 158 + let c: Coord<19, 19> = "T19".try_into().unwrap(); 159 + assert_eq!(c.x(), 18); 160 + assert_eq!(c.y(), 18); 161 + 162 + let c: Coord<19, 19> = "J10".try_into().unwrap(); 163 + assert_eq!(c.x(), 8); 164 + assert_eq!(c.y(), 9); 165 + } 166 + 167 + #[test] 168 + fn from_gtp_out_of_bounds() { 169 + let result: Result<Coord<9, 9>, _> = "K1".try_into(); 170 + assert_eq!(result, Err(GoError::OutOfBounds)); 171 + 172 + let result: Result<Coord<9, 9>, _> = "A10".try_into(); 173 + assert_eq!(result, Err(GoError::OutOfBounds)); 174 + } 175 + 176 + #[test] 177 + fn from_gtp_invalid_format() { 178 + let result: Result<Coord<19, 19>, _> = "".try_into(); 179 + assert_eq!(result, Err(GoError::InvalidCoordinateFormat)); 180 + 181 + let result: Result<Coord<19, 19>, _> = "1A".try_into(); 182 + assert_eq!(result, Err(GoError::InvalidCoordinateFormat)); 183 + 184 + let result: Result<Coord<19, 19>, _> = "AA1".try_into(); 185 + assert_eq!(result, Err(GoError::InvalidCoordinateFormat)); 186 + } 187 + 188 + #[test] 189 + fn from_sgf_valid() { 190 + let c: Coord<19, 19> = "aa".try_into().unwrap(); 191 + assert_eq!(c.x(), 0); 192 + assert_eq!(c.y(), 0); 193 + 194 + let c: Coord<19, 19> = "ss".try_into().unwrap(); 195 + assert_eq!(c.x(), 18); 196 + assert_eq!(c.y(), 18); 197 + } 198 + 199 + #[test] 200 + fn from_sgf_out_of_bounds() { 201 + let result: Result<Coord<9, 9>, _> = "jj".try_into(); 202 + assert_eq!(result, Err(GoError::OutOfBounds)); 203 + } 204 + 205 + #[test] 206 + fn to_gtp_string() { 207 + let c: Coord<19, 19> = (0, 0).try_into().unwrap(); 208 + assert_eq!(c.to_gtp_string(), Some("A1".to_string())); 209 + 210 + let c: Coord<19, 19> = (8, 9).try_into().unwrap(); 211 + assert_eq!(c.to_gtp_string(), Some("J10".to_string())); 212 + 213 + let c: Coord<19, 19> = (18, 18).try_into().unwrap(); 214 + assert_eq!(c.to_gtp_string(), Some("T19".to_string())); 215 + } 216 + 217 + #[test] 218 + fn to_sgf_string() { 219 + let c: Coord<19, 19> = (0, 0).try_into().unwrap(); 220 + assert_eq!(c.to_sgf_string(), "aa"); 221 + 222 + let c: Coord<19, 19> = (18, 18).try_into().unwrap(); 223 + assert_eq!(c.to_sgf_string(), "ss"); 224 + } 225 + }
+17
src/error.rs
··· 1 + use thiserror::Error; 2 + 3 + #[derive(Error, Debug, PartialEq, Eq, Clone, Copy)] 4 + pub enum GoError { 5 + #[error("coordinate out of bounds")] 6 + OutOfBounds, 7 + #[error("invalid coordinate format")] 8 + InvalidCoordinateFormat, 9 + #[error("point is occupied")] 10 + Occupied, 11 + #[error("move is suicide")] 12 + Suicide, 13 + #[error("ko violation")] 14 + Ko, 15 + #[error("not your turn")] 16 + NotYourTurn, 17 + }
+7
src/lib.rs
··· 1 + pub mod color; 2 + pub mod coord; 3 + pub mod error; 4 + 5 + pub use color::Color; 6 + pub use coord::Coord; 7 + pub use error::GoError;