Class: PGN::Board
- Inherits:
-
Object
- Object
- PGN::Board
- Defined in:
- lib/pgn/board.rb
Overview
Board represents the squares of a chess board and the pieces on each square. It is responsible for translating between a human readable format (white queen's rook on the bottom left) and the obvious internal representation (white queen's rook is position [0,0]). It takes care of converting square names (e4) to actual locations, and can convert to unicode chess pieces for display purposes.
Constant Summary collapse
- START =
The starting, internal representation of a chess board
[ ['R', 'P', nil, nil, nil, nil, 'p', 'r'], ['N', 'P', nil, nil, nil, nil, 'p', 'n'], ['B', 'P', nil, nil, nil, nil, 'p', 'b'], ['Q', 'P', nil, nil, nil, nil, 'p', 'q'], ['K', 'P', nil, nil, nil, nil, 'p', 'k'], ['B', 'P', nil, nil, nil, nil, 'p', 'b'], ['N', 'P', nil, nil, nil, nil, 'p', 'n'], ['R', 'P', nil, nil, nil, nil, 'p', 'r'] ].freeze
- FILE_TO_INDEX =
('a'..'h').each_with_index.to_h
- INDEX_TO_FILE =
FILE_TO_INDEX.invert
- RANK_TO_INDEX =
('1'..'8').each_with_index.to_h
- INDEX_TO_RANK =
RANK_TO_INDEX.invert
- KNIGHT_OFFS =
0x88 knight offsets (a1 + 33 = b3, etc.).
[33, 31, -31, -33, 18, 14, -14, -18].freeze
- KING_OFFS =
[-1, 1, -16, 16, -15, 15, -17, 17].freeze
- KNIGHT_ATTACKS =
Precomputed on-board attack masks: entry
idxis the frozen Array of on-board 0x88 target indices reachable fromidxby that piece. Built once at load time so the per-call offset + off-board test is replaced by a direct array iteration. Array.new(128) do |idx| next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate KNIGHT_OFFS.each_with_object([]) do |off, a| t = idx + off a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate end.freeze end.freeze
- KING_ATTACKS =
Array.new(128) do |idx| next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate KING_OFFS.each_with_object([]) do |off, a| t = idx + off a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate end.freeze end.freeze
- UNICODE_PIECES =
algebraic to unicode piece lookup
{ 'k' => "\u{265A}", 'q' => "\u{265B}", 'r' => "\u{265C}", 'b' => "\u{265D}", 'n' => "\u{265E}", 'p' => "\u{265F}", 'K' => "\u{2654}", 'Q' => "\u{2655}", 'R' => "\u{2656}", 'B' => "\u{2657}", 'N' => "\u{2658}", 'P' => "\u{2659}", nil => '_' }.freeze
Instance Attribute Summary collapse
-
#squares ⇒ Array<Array<String>>
The board as a file-major 8x8 array (squares[rank]).
Class Method Summary collapse
-
.from_cells(cells) ⇒ PGN::Board
Build a Board directly from a 0x88 cell array, bypassing the 8x8 -> 0x88 conversion in #initialize.
-
.start ⇒ PGN::Board
A board in the starting position.
Instance Method Summary collapse
-
#apply!(changes) ⇒ self
Applies a batch of integer-indexed changes.
-
#at(arg0, arg1 = nil) ⇒ String?
The piece on the square, or nil if it is empty.
-
#at_index(idx) ⇒ String?
Looks up a piece by 0x88 index.
- #change!(changes) ⇒ self
-
#coordinates_for(position) ⇒ Array<Integer>
The coordinates of the square.
-
#dup ⇒ PGN::Board
A copy of self.
-
#eql?(other) ⇒ Boolean
(also: #==)
Boards are equal when every cell holds the same piece (or is empty), including off-board padding, which is always nil on both.
-
#fen_board_string ⇒ String
Serializes the board to the FEN board-string portion (ranks 8→1, files a→h, runs of empty squares collapsed to a digit) by walking the 0x88
@cellsarray directly. -
#index_for(file, rank) ⇒ Integer
The 0x88 index of zero-indexed file/rank coordinates.
-
#index_of(square) ⇒ Integer
The 0x88 index of an algebraic square name.
-
#initialize(squares) ⇒ Board
constructor
A new instance of Board.
-
#inspect ⇒ String
The board in human readable format with unicode pieces.
-
#on_board?(idx) ⇒ Boolean
Whether a 0x88 index is on the board (see the class doc for the bitmask this tests).
-
#position_for(coordinates) ⇒ String
The square in algebraic notation.
-
#square_name(idx) ⇒ String
The algebraic square name of a 0x88 index.
- #update(square, piece) ⇒ self
-
#update_index(idx, piece) ⇒ self
Places a piece on a 0x88 index.
Constructor Details
#initialize(squares) ⇒ Board
Returns a new instance of Board.
111 112 113 |
# File 'lib/pgn/board.rb', line 111 def initialize(squares) self.squares = squares end |
Instance Attribute Details
#squares ⇒ Array<Array<String>>
Returns the board as a file-major 8x8 array (squares[rank]). Built on demand from the 0x88 array; equality with the START constant and other boards is preserved.
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 |
# File 'lib/pgn/board.rb', line 15 class Board # The starting, internal representation of a chess board # START = [ ['R', 'P', nil, nil, nil, nil, 'p', 'r'], ['N', 'P', nil, nil, nil, nil, 'p', 'n'], ['B', 'P', nil, nil, nil, nil, 'p', 'b'], ['Q', 'P', nil, nil, nil, nil, 'p', 'q'], ['K', 'P', nil, nil, nil, nil, 'p', 'k'], ['B', 'P', nil, nil, nil, nil, 'p', 'b'], ['N', 'P', nil, nil, nil, nil, 'p', 'n'], ['R', 'P', nil, nil, nil, nil, 'p', 'r'] ].freeze FILE_TO_INDEX = ('a'..'h').each_with_index.to_h INDEX_TO_FILE = FILE_TO_INDEX.invert RANK_TO_INDEX = ('1'..'8').each_with_index.to_h INDEX_TO_RANK = RANK_TO_INDEX.invert # 0x88 knight offsets (a1 + 33 = b3, etc.). KNIGHT_OFFS = [33, 31, -31, -33, 18, 14, -14, -18].freeze KING_OFFS = [-1, 1, -16, 16, -15, 15, -17, 17].freeze # Precomputed on-board attack masks: entry `idx` is the frozen Array of # on-board 0x88 target indices reachable from `idx` by that piece. Built # once at load time so the per-call offset + off-board test is replaced by # a direct array iteration. KNIGHT_ATTACKS = Array.new(128) do |idx| next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate KNIGHT_OFFS.each_with_object([]) do |off, a| t = idx + off a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate end.freeze end.freeze KING_ATTACKS = Array.new(128) do |idx| next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate KING_OFFS.each_with_object([]) do |off, a| t = idx + off a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate end.freeze end.freeze # algebraic to unicode piece lookup # UNICODE_PIECES = { 'k' => "\u{265A}", 'q' => "\u{265B}", 'r' => "\u{265C}", 'b' => "\u{265D}", 'n' => "\u{265E}", 'p' => "\u{265F}", 'K' => "\u{2654}", 'Q' => "\u{2655}", 'R' => "\u{2656}", 'B' => "\u{2657}", 'N' => "\u{2658}", 'P' => "\u{2659}", nil => '_' }.freeze # 0x88 board representation (see chess.js / the classic 0x88 move-generation # algorithm). A square is addressed by a single integer index # `rank * 16 + file`; the extra files/ranks make off-board detection a # single bitmask test -- `(idx & 0x88) != 0` -- which is faster than the # four-integer comparison a 0..7 bounds check needs, and lets ray # stepping be a single integer add. The public `squares` 8x8 API is built # from this array on demand (it is off the replay hot path), and the # MoveCalculator hot path works entirely in integer indices. # # file = idx & 0x0F (0..7) # rank = idx >> 4 (0..7) # @return [PGN::Board] a board in the starting position # def self.start PGN::Board.new(START) end # @param squares [<Array<Array<String>>>] the squares of the board # @example # PGN::Board.new( # [ # ["R", "P", nil, nil, nil, nil, "p", "r"], # ["N", "P", nil, nil, nil, nil, "p", "n"], # ["B", "P", nil, nil, nil, nil, "p", "b"], # ["Q", "P", nil, nil, nil, nil, "p", "q"], # ["K", "P", nil, nil, nil, nil, "p", "k"], # ["B", "P", nil, nil, nil, nil, "p", "b"], # ["N", "P", nil, nil, nil, nil, "p", "n"], # ["R", "P", nil, nil, nil, nil, "p", "r"], # ] # ) # def initialize(squares) self.squares = squares end # @return [Array<Array<String>>] the board as a file-major 8x8 array # (squares[file][rank]). Built on demand from the 0x88 array; equality # with the START constant and other boards is preserved. # def squares (0..7).map { |f| (0..7).map { |r| @cells[(r * 16) + f] } } end def squares=(squares) @cells = Array.new(128) 8.times do |f| 8.times do |r| @cells[(r * 16) + f] = squares[f][r] end end end # @overload at(str) # Looks up a piece based on the string representation of a square (e4) # @param str [String] the square in algebraic notation # @overload at(file, rank) # Looks up a piece based on zero-indexed coordinates (4, 3) # @param file [Integer] the file the piece is on # @param rank [Integer] the rank the piece is on # @return [String, nil] the piece on the square, or nil if it is # empty # @example # board.at(4,3) #=> "P" # board.at("e4") #=> "P" # def at(arg0, arg1 = nil) return at_index(index_for(arg0, arg1)) unless arg1.nil? at_index(index_of(arg0)) end # @param changes [Hash<String, <String, nil>>] changes to make to the board # @return [self] # @example # board.change!({"e2" => nil, "e4" => "P"}) # def change!(changes) changes.each { |square, piece| update(square, piece) } self end # @param square [String] the square in algebraic notation # @param piece [String, nil] the piece to put on the square # @return [self] # @example # board.update("e4", "P") # def update(square, piece) update_index(index_of(square), piece) end # @param position [String] the square in algebraic notation # @return [Array<Integer>] the coordinates of the square # @example # board.coordinates_for("e4") #=> [4, 3] # def coordinates_for(position) [file_of(position), rank_of(position)] end # @param coordinates [Array<Integer>] the coordinates of the square # @return [String] the square in algebraic notation # @example # board.position_for([4, 3]) #=> "e4" # def position_for(coordinates) file, rank = coordinates INDEX_TO_FILE[file] + INDEX_TO_RANK[rank] end # @return [String] the board in human readable format with unicode # pieces # def inspect squares.transpose.reverse.map do |row| row.map { |chr| UNICODE_PIECES[chr] }.join(' ') end.join("\n") end # Build a {Board} directly from a 0x88 cell array, bypassing the 8x8 # -> 0x88 conversion in {#initialize}. Used by {#dup} (which runs every # move) to skip the per-square rebuild; the cell array is already in the # canonical 128-cell layout. # # @param cells [Array<String, nil>] a 128-cell 0x88 array # @return [PGN::Board] # def self.from_cells(cells) board = allocate board.instance_variable_set(:@cells, cells) board end # @return [PGN::Board] a copy of self. Copies the 128-cell 0x88 array; # mutations to the copy do not affect the original. # def dup self.class.from_cells(@cells.dup) end # -- 0x88 hot-path API (integer indices) --------------------------------- # The 0x88 index of an algebraic square name. # # @param square [String] e.g. "e4" # @return [Integer] idx = rank * 16 + file # def index_of(square) (rank_of(square) * 16) + file_of(square) end # The 0x88 index of zero-indexed file/rank coordinates. # # @return [Integer] idx = rank * 16 + file # def index_for(file, rank) (rank * 16) + file end # Looks up a piece by 0x88 index. The caller is responsible for having # already verified the index is on-board (`(idx & 0x88).zero?`); reading # an off-board index simply returns nil. # # @param idx [Integer] a 0x88 square index # @return [String, nil] the piece on that square # def at_index(idx) @cells[idx] end # Places a piece on a 0x88 index. Returns self. # # @param idx [Integer] a 0x88 square index # @param piece [String, nil] # @return [self] # def update_index(idx, piece) @cells[idx] = piece self end # Applies a batch of integer-indexed changes. The replay hot path uses # this so it never allocates square-name strings or `[file, rank]` # coordinate arrays. # # @param changes [Hash<Integer, <String, nil>>] # @return [self] # def apply!(changes) changes.each { |idx, piece| @cells[idx] = piece } self end # Whether a 0x88 index is on the board (see the class doc for the # bitmask this tests). # # @param idx [Integer] a 0x88 square index # @return [Boolean] # def on_board?(idx) (idx & 0x88).zero? # rubocop:disable Style/BitwisePredicate end # The algebraic square name of a 0x88 index. # # @param idx [Integer] a 0x88 square index # @return [String] e.g. "e4" # def square_name(idx) INDEX_TO_FILE[idx & 0x0F] + INDEX_TO_RANK[idx >> 4] end # Serializes the board to the FEN board-string portion (ranks 8→1, # files a→h, runs of empty squares collapsed to a digit) by walking # the 0x88 `@cells` array directly. This avoids rebuilding the 8x8 # `squares` array on every FEN generation. # # @return [String] e.g. "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" def fen_board_string rows = [] 7.downto(0) do |rank| s = String.new run = 0 0.upto(7) do |file| piece = @cells[(rank * 16) + file] if piece.nil? run += 1 else s << run.to_s if run.positive? run = 0 s << piece end end s << run.to_s if run.positive? rows << s end rows.join('/') end # Boards are equal when every cell holds the same piece (or is # empty), including off-board padding, which is always nil on both. def eql?(other) other.is_a?(Board) && cells == other.cells end alias == eql? protected attr_reader :cells private def file_of(square) square.getbyte(0) - 97 end def rank_of(square) square.getbyte(1) - 49 end end |
Class Method Details
.from_cells(cells) ⇒ PGN::Board
Build a PGN::Board directly from a 0x88 cell array, bypassing the 8x8 -> 0x88 conversion in #initialize. Used by #dup (which runs every move) to skip the per-square rebuild; the cell array is already in the canonical 128-cell layout.
207 208 209 210 211 |
# File 'lib/pgn/board.rb', line 207 def self.from_cells(cells) board = allocate board.instance_variable_set(:@cells, cells) board end |
.start ⇒ PGN::Board
Returns a board in the starting position.
92 93 94 |
# File 'lib/pgn/board.rb', line 92 def self.start PGN::Board.new(START) end |
Instance Method Details
#apply!(changes) ⇒ self
Applies a batch of integer-indexed changes. The replay hot path uses
this so it never allocates square-name strings or [file, rank]
coordinate arrays.
268 269 270 271 |
# File 'lib/pgn/board.rb', line 268 def apply!(changes) changes.each { |idx, piece| @cells[idx] = piece } self end |
#at(str) ⇒ String? #at(file, rank) ⇒ String?
Returns the piece on the square, or nil if it is empty.
145 146 147 148 149 |
# File 'lib/pgn/board.rb', line 145 def at(arg0, arg1 = nil) return at_index(index_for(arg0, arg1)) unless arg1.nil? at_index(index_of(arg0)) end |
#at_index(idx) ⇒ String?
Looks up a piece by 0x88 index. The caller is responsible for having
already verified the index is on-board ((idx & 0x88).zero?); reading
an off-board index simply returns nil.
246 247 248 |
# File 'lib/pgn/board.rb', line 246 def at_index(idx) @cells[idx] end |
#change!(changes) ⇒ self
156 157 158 159 |
# File 'lib/pgn/board.rb', line 156 def change!(changes) changes.each { |square, piece| update(square, piece) } self end |
#coordinates_for(position) ⇒ Array<Integer>
Returns the coordinates of the square.
176 177 178 |
# File 'lib/pgn/board.rb', line 176 def coordinates_for(position) [file_of(position), rank_of(position)] end |
#dup ⇒ PGN::Board
Returns a copy of self. Copies the 128-cell 0x88 array; mutations to the copy do not affect the original.
216 217 218 |
# File 'lib/pgn/board.rb', line 216 def dup self.class.from_cells(@cells.dup) end |
#eql?(other) ⇒ Boolean Also known as: ==
Boards are equal when every cell holds the same piece (or is empty), including off-board padding, which is always nil on both.
321 322 323 |
# File 'lib/pgn/board.rb', line 321 def eql?(other) other.is_a?(Board) && cells == other.cells end |
#fen_board_string ⇒ String
Serializes the board to the FEN board-string portion (ranks 8→1,
files a→h, runs of empty squares collapsed to a digit) by walking
the 0x88 @cells array directly. This avoids rebuilding the 8x8
squares array on every FEN generation.
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 |
# File 'lib/pgn/board.rb', line 298 def fen_board_string rows = [] 7.downto(0) do |rank| s = String.new run = 0 0.upto(7) do |file| piece = @cells[(rank * 16) + file] if piece.nil? run += 1 else s << run.to_s if run.positive? run = 0 s << piece end end s << run.to_s if run.positive? rows << s end rows.join('/') end |
#index_for(file, rank) ⇒ Integer
The 0x88 index of zero-indexed file/rank coordinates.
235 236 237 |
# File 'lib/pgn/board.rb', line 235 def index_for(file, rank) (rank * 16) + file end |
#index_of(square) ⇒ Integer
The 0x88 index of an algebraic square name.
227 228 229 |
# File 'lib/pgn/board.rb', line 227 def index_of(square) (rank_of(square) * 16) + file_of(square) end |
#inspect ⇒ String
Returns the board in human readable format with unicode pieces.
193 194 195 196 197 |
# File 'lib/pgn/board.rb', line 193 def inspect squares.transpose.reverse.map do |row| row.map { |chr| UNICODE_PIECES[chr] }.join(' ') end.join("\n") end |
#on_board?(idx) ⇒ Boolean
Whether a 0x88 index is on the board (see the class doc for the bitmask this tests).
279 280 281 |
# File 'lib/pgn/board.rb', line 279 def on_board?(idx) (idx & 0x88).zero? # rubocop:disable Style/BitwisePredicate end |
#position_for(coordinates) ⇒ String
Returns the square in algebraic notation.
185 186 187 188 |
# File 'lib/pgn/board.rb', line 185 def position_for(coordinates) file, rank = coordinates INDEX_TO_FILE[file] + INDEX_TO_RANK[rank] end |
#square_name(idx) ⇒ String
The algebraic square name of a 0x88 index.
288 289 290 |
# File 'lib/pgn/board.rb', line 288 def square_name(idx) INDEX_TO_FILE[idx & 0x0F] + INDEX_TO_RANK[idx >> 4] end |
#update(square, piece) ⇒ self
167 168 169 |
# File 'lib/pgn/board.rb', line 167 def update(square, piece) update_index(index_of(square), piece) end |
#update_index(idx, piece) ⇒ self
Places a piece on a 0x88 index. Returns self.
256 257 258 259 |
# File 'lib/pgn/board.rb', line 256 def update_index(idx, piece) @cells[idx] = piece self end |