Class: PGN::Position
- Inherits:
-
Object
- Object
- PGN::Position
- Defined in:
- lib/pgn/position.rb
Overview
Position encapsulates all of the information necessary to completely understand a chess position. It can be turned into a FEN string or perform a move.
Constant Summary collapse
- PLAYERS =
%i[white black].freeze
- CASTLING =
%w[K Q k q].freeze
Instance Attribute Summary collapse
-
#board ⇒ PGN::Board
The board for the position.
-
#castling ⇒ Array<String>
The castling moves that are still available.
-
#en_passant ⇒ String
The en passant square if applicable.
-
#fullmove ⇒ Integer
The number of fullmoves made so far.
-
#halfmove ⇒ Integer
The number of halfmoves since the last pawn move or capture.
-
#player ⇒ Symbol
The player who moves next.
Class Method Summary collapse
-
.start ⇒ PGN::Position
The starting position of a chess game.
Instance Method Summary collapse
-
#attackers(square, color = opponent_color) ⇒ Array<String>
The algebraic squares of every piece of the given color that attacks
square(algebraic, e.g. "e4"). -
#checkmate? ⇒ Boolean
Whether the side to move has been checkmated (in check and no legal move).
-
#eql?(other) ⇒ Boolean
(also: #==)
Positions are equal when their board, side to move, castling rights, and en-passant square match.
-
#fifty_move? ⇒ Boolean
Whether the 50-move rule applies (100 halfmoves since the last pawn move or capture).
- #hash ⇒ Object
-
#in_check? ⇒ Boolean
Whether the side to move's king is currently in check.
-
#initialize(board, player, castling = CASTLING, en_passant = nil, halfmove = 0, fullmove = 1) ⇒ Position
constructor
A new instance of Position.
- #inspect ⇒ Object
-
#insufficient_material? ⇒ Boolean
Whether the position has insufficient material to mate.
-
#legal?(move) ⇒ Boolean
Whether
moveis legal. -
#legal_moves ⇒ Array<String>
All legal moves from this position as sorted UCI strings (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed by the native bitboard engine via a FEN round-trip.
-
#legal_moves_san ⇒ Array<String>
All legal moves from this position as sorted SAN strings, computed by delegating the native engine's UCI move list through Notation.san.
-
#move(str) ⇒ PGN::Position
The resulting position.
-
#mover_color ⇒ Object
The color string for the side to move ('w'/'b').
-
#next_player ⇒ Symbol
The next player to move.
-
#opponent_color ⇒ Object
The color string for the opponent of the side to move ('w'/'b').
-
#outcome ⇒ Symbol?
The terminal status of this position: :checkmate, :stalemate, or :draw (insufficient material or 50-move rule).
-
#perft(depth) ⇒ Integer
The perft node count at
depthfrom this position, computed by the native bitboard engine via a FEN round-trip. -
#stalemate? ⇒ Boolean
Whether the side to move has been stalemated (not in check and no legal move).
-
#to_fen ⇒ PGN::FEN
A FEN object representing the current position.
-
#zobrist ⇒ Integer
The Zobrist hash of the position.
Constructor Details
#initialize(board, player, castling = CASTLING, en_passant = nil, halfmove = 0, fullmove = 1) ⇒ Position
Returns a new instance of Position.
62 63 64 65 66 67 68 69 |
# File 'lib/pgn/position.rb', line 62 def initialize(board, player, castling = CASTLING, en_passant = nil, halfmove = 0, fullmove = 1) self.board = board self.player = player self.castling = castling self.en_passant = en_passant self.halfmove = halfmove self.fullmove = fullmove end |
Instance Attribute Details
#board ⇒ PGN::Board
Returns the board for the position.
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 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 |
# File 'lib/pgn/position.rb', line 32 class Position PLAYERS = %i[white black].freeze CASTLING = %w[K Q k q].freeze attr_accessor :board, :player, :castling, :en_passant, :halfmove, :fullmove # @return [PGN::Position] the starting position of a chess game # def self.start PGN::Position.new( PGN::Board.start, PLAYERS.first ) end # @param board [PGN::Board] the board for the position # @param player [Symbol] the player who moves next # @param castling [Array<String>] the castling moves that are still # available # @param en_passant [String, nil] the en passant square if applicable # @param halfmove [Integer] the number of halfmoves since the last pawn # move or capture # @param fullmove [Integer] the number of fullmoves made so far # # @example # PGN::Position.new( # PGN::Board.start, # :white, # ) # def initialize(board, player, castling = CASTLING, en_passant = nil, halfmove = 0, fullmove = 1) self.board = board self.player = player self.castling = castling self.en_passant = en_passant self.halfmove = halfmove self.fullmove = fullmove end # @param str [String] the move to make in SAN # @return [PGN::Position] the resulting position # # @example # queens_pawn = PGN::Position.start.move("d4") # def move(str) move = PGN::Move.new(str, player) calculator = PGN::MoveCalculator.new(board, move) restrictions = calculator.castling_restrictions new_castling = restrictions.empty? ? castling : castling - restrictions new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0 new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove no_move = str == '--' PGN::Position.new( no_move ? board : calculator.result_board, next_player, new_castling, calculator.en_passant_square, new_halfmove, new_fullmove ) end # @return [Symbol] the next player to move # def next_player player == :white ? :black : :white end # The perft node count at +depth+ from this position, computed by the # native bitboard engine via a FEN round-trip. Requires the compiled # native extension (the shipped gem); raises NameError if it is absent. # # @param depth [Integer] search depth, >= 0 # @return [Integer] # def perft(depth) raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0 PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth) end # All legal moves from this position as sorted UCI strings # (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed # by the native bitboard engine via a FEN round-trip. Requires the # compiled native extension; raises NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves end # All legal moves from this position as sorted SAN strings, computed # by delegating the native engine's UCI move list through # {PGN::Notation.san}. Requires the compiled native extension; raises # NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves_san engine = PGN::Bitboard::Engine.new(to_fen.to_s) engine.legal_moves.map { |uci| uci_to_san(uci) }.sort end # Whether +move+ is legal. Accepts SAN ("Nf3", "e4", "O-O", "a8=Q", # "Qxf7#") or UCI ("g1f3", "e2e4", "e1g1", "a7a8q"). Requires the # compiled native extension; raises NameError if it is absent. # # UCI is handed straight to the engine. SAN is resolved against the # engine's legal move list: a SAN string is legal only when it points # at exactly one legal move (so an ambiguous "Nd2" with two knights # is rejected, while "Nbd2"/"Nfd2" are accepted). The engine remains # the single source of truth for legality (king safety, pins, etc.). # # @param move [String] SAN or UCI # @return [Boolean] def legal?(move) engine = PGN::Bitboard::Engine.new(to_fen.to_s) return engine.legal?(move) if uci?(move) parsed = PGN::Move.new(move, player) return false if parsed.destination.nil? && parsed.castle.nil? candidates = engine.legal_moves.select { |uci| matches_san?(uci, parsed) } candidates.size == 1 rescue StandardError false end def inspect "\n#{board.inspect}" end # The color string for the side to move ('w'/'b'). def mover_color player == :white ? 'w' : 'b' end # The color string for the opponent of the side to move ('w'/'b'). def opponent_color player == :white ? 'b' : 'w' end # Whether the side to move's king is currently in check. # # @return [Boolean] def in_check? king = PGN::Attack.king_idx(board, mover_color) !king.nil? && PGN::Attack.attacked?(board, king, opponent_color) end # The algebraic squares of every piece of the given color that attacks # +square+ (algebraic, e.g. "e4"). Defaults to the side to move's # opponent, which is useful for check detection. # # @param square [String] e.g. "e4" # @param color [String, nil] 'w' or 'b'; defaults to the opponent color # @return [Array<String>] squares attacking +square+ def attackers(square, color = opponent_color) PGN::Attack.attackers(board, board.index_of(square), color) end # Whether the side to move has been checkmated (in check and no legal # move). Requires the native extension for legal-move enumeration. # # @return [Boolean] def checkmate? in_check? && legal_moves.empty? end # Whether the side to move has been stalemated (not in check and no # legal move). Requires the native extension. # # @return [Boolean] def stalemate? !in_check? && legal_moves.empty? end # Whether the position has insufficient material to mate. Covers K vs K, # K + one minor vs K, and same-colored bishops only. # # @return [Boolean] def insufficient_material? non_king = (0...128).each_with_object([]) do |idx, acc| next if idx.anybits?(0x88) piece = board.at_index(idx) acc << [piece, idx] if piece && piece.upcase != 'K' end return true if non_king.empty? return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase) bishops_same_color?(non_king) end # Whether the 50-move rule applies (100 halfmoves since the last pawn # move or capture). # # @return [Boolean] def fifty_move? halfmove >= 100 end # The terminal status of this position: :checkmate, :stalemate, or :draw # (insufficient material or 50-move rule). nil if the position is still # in progress. Threefold repetition requires game history and is # answered by {PGN::Game#outcome}. # # @return [Symbol, nil] def outcome return :checkmate if checkmate? return :stalemate if stalemate? return :draw if insufficient_material? || fifty_move? nil end # @return [PGN::FEN] a {PGN::FEN} object representing the current position # def to_fen PGN::FEN.from_attributes( board: board, active: player == :white ? 'w' : 'b', castling: castling.join, en_passant: en_passant, halfmove: halfmove.to_s, fullmove: fullmove.to_s ) end # Positions are equal when their board, side to move, castling rights, # and en-passant square match. Halfmove/fullmove counters are ignored # (matching threefold-repetition semantics). def eql?(other) other.is_a?(PGN::Position) && player == other.player && castling == other.castling && en_passant == other.en_passant && zobrist == other.zobrist && board == other.board end alias == eql? def hash zobrist end # The Zobrist hash of the position. Computed lazily on first access and # cached, so the replay hot path (which never asks for the hash) pays # nothing; consumers like threefold-repetition checks pay one full seed. # # @return [Integer] def zobrist @zobrist ||= Zobrist.seed(board, player, castling, en_passant) end private # True when +move+ looks like a UCI coordinate string ("e2e4", # "e1g1", "a7a8q"), so it can be handed straight to the engine. def uci?(move) move.is_a?(String) && move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/) end # Convert a UCI string to SAN using the current position, for # {#legal_moves_san}. Promotion (if present) is passed as the letter. def uci_to_san(uci) from = uci[0, 2] to = uci[2, 2] promo = uci[4] PGN::Notation.san(self, from, to, promo) end # Map a castling side letter from {PGN::Move#castle} to the king's # from/to UCI squares for the side to move. CASTLE_UCI = { 'K' => 'e1g1', 'Q' => 'e1c1', 'k' => 'e8g8', 'q' => 'e8c8' }.freeze private_constant :CASTLE_UCI # Does the legal UCI move +uci+ match the parsed SAN +move+? A move # matches when destination, piece, promotion, and castling all agree, # and the origin square satisfies +move+'s disambiguation (if any). def matches_san?(uci, move) return uci == CASTLE_UCI[move.castle] if move.castle to = uci[2, 2] return false if move.destination != to from_idx = board.index_of(uci[0, 2]) return false if board.at_index(from_idx) != move.piece promo = uci[4] return false if move.promotion&.downcase != promo disambiguation_matches?(move.disambiguation, from_idx) end # Whether the disambiguation string from SAN (a file, a rank, or a full # square) describes the origin square at +from_idx+. nil disambiguation # matches any origin (ambiguity is handled by the caller counting matches). def disambiguation_matches?(disambiguation, from_idx) return true if disambiguation.nil? || disambiguation.empty? file = Board::INDEX_TO_FILE[from_idx & 0x0F] rank = Board::INDEX_TO_RANK[from_idx >> 4] case disambiguation when /\A[a-h]\z/ then file == disambiguation when /\A[1-8]\z/ then rank == disambiguation else file + rank == disambiguation end end # Whether +non_king+ is all bishops on squares of the same color (a known # insufficient-material draw, e.g. KB vs KB with same-colored bishops). def bishops_same_color?(non_king) return false unless non_king.all? { |piece, _| piece.upcase == 'B' } return false if non_king.size > 2 colors = non_king.map { |_piece, idx| ((idx & 0x0F) + (idx >> 4)) % 2 } colors.uniq.size == 1 end end |
#castling ⇒ Array<String>
Returns the castling moves that are still available.
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 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 |
# File 'lib/pgn/position.rb', line 32 class Position PLAYERS = %i[white black].freeze CASTLING = %w[K Q k q].freeze attr_accessor :board, :player, :castling, :en_passant, :halfmove, :fullmove # @return [PGN::Position] the starting position of a chess game # def self.start PGN::Position.new( PGN::Board.start, PLAYERS.first ) end # @param board [PGN::Board] the board for the position # @param player [Symbol] the player who moves next # @param castling [Array<String>] the castling moves that are still # available # @param en_passant [String, nil] the en passant square if applicable # @param halfmove [Integer] the number of halfmoves since the last pawn # move or capture # @param fullmove [Integer] the number of fullmoves made so far # # @example # PGN::Position.new( # PGN::Board.start, # :white, # ) # def initialize(board, player, castling = CASTLING, en_passant = nil, halfmove = 0, fullmove = 1) self.board = board self.player = player self.castling = castling self.en_passant = en_passant self.halfmove = halfmove self.fullmove = fullmove end # @param str [String] the move to make in SAN # @return [PGN::Position] the resulting position # # @example # queens_pawn = PGN::Position.start.move("d4") # def move(str) move = PGN::Move.new(str, player) calculator = PGN::MoveCalculator.new(board, move) restrictions = calculator.castling_restrictions new_castling = restrictions.empty? ? castling : castling - restrictions new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0 new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove no_move = str == '--' PGN::Position.new( no_move ? board : calculator.result_board, next_player, new_castling, calculator.en_passant_square, new_halfmove, new_fullmove ) end # @return [Symbol] the next player to move # def next_player player == :white ? :black : :white end # The perft node count at +depth+ from this position, computed by the # native bitboard engine via a FEN round-trip. Requires the compiled # native extension (the shipped gem); raises NameError if it is absent. # # @param depth [Integer] search depth, >= 0 # @return [Integer] # def perft(depth) raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0 PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth) end # All legal moves from this position as sorted UCI strings # (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed # by the native bitboard engine via a FEN round-trip. Requires the # compiled native extension; raises NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves end # All legal moves from this position as sorted SAN strings, computed # by delegating the native engine's UCI move list through # {PGN::Notation.san}. Requires the compiled native extension; raises # NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves_san engine = PGN::Bitboard::Engine.new(to_fen.to_s) engine.legal_moves.map { |uci| uci_to_san(uci) }.sort end # Whether +move+ is legal. Accepts SAN ("Nf3", "e4", "O-O", "a8=Q", # "Qxf7#") or UCI ("g1f3", "e2e4", "e1g1", "a7a8q"). Requires the # compiled native extension; raises NameError if it is absent. # # UCI is handed straight to the engine. SAN is resolved against the # engine's legal move list: a SAN string is legal only when it points # at exactly one legal move (so an ambiguous "Nd2" with two knights # is rejected, while "Nbd2"/"Nfd2" are accepted). The engine remains # the single source of truth for legality (king safety, pins, etc.). # # @param move [String] SAN or UCI # @return [Boolean] def legal?(move) engine = PGN::Bitboard::Engine.new(to_fen.to_s) return engine.legal?(move) if uci?(move) parsed = PGN::Move.new(move, player) return false if parsed.destination.nil? && parsed.castle.nil? candidates = engine.legal_moves.select { |uci| matches_san?(uci, parsed) } candidates.size == 1 rescue StandardError false end def inspect "\n#{board.inspect}" end # The color string for the side to move ('w'/'b'). def mover_color player == :white ? 'w' : 'b' end # The color string for the opponent of the side to move ('w'/'b'). def opponent_color player == :white ? 'b' : 'w' end # Whether the side to move's king is currently in check. # # @return [Boolean] def in_check? king = PGN::Attack.king_idx(board, mover_color) !king.nil? && PGN::Attack.attacked?(board, king, opponent_color) end # The algebraic squares of every piece of the given color that attacks # +square+ (algebraic, e.g. "e4"). Defaults to the side to move's # opponent, which is useful for check detection. # # @param square [String] e.g. "e4" # @param color [String, nil] 'w' or 'b'; defaults to the opponent color # @return [Array<String>] squares attacking +square+ def attackers(square, color = opponent_color) PGN::Attack.attackers(board, board.index_of(square), color) end # Whether the side to move has been checkmated (in check and no legal # move). Requires the native extension for legal-move enumeration. # # @return [Boolean] def checkmate? in_check? && legal_moves.empty? end # Whether the side to move has been stalemated (not in check and no # legal move). Requires the native extension. # # @return [Boolean] def stalemate? !in_check? && legal_moves.empty? end # Whether the position has insufficient material to mate. Covers K vs K, # K + one minor vs K, and same-colored bishops only. # # @return [Boolean] def insufficient_material? non_king = (0...128).each_with_object([]) do |idx, acc| next if idx.anybits?(0x88) piece = board.at_index(idx) acc << [piece, idx] if piece && piece.upcase != 'K' end return true if non_king.empty? return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase) bishops_same_color?(non_king) end # Whether the 50-move rule applies (100 halfmoves since the last pawn # move or capture). # # @return [Boolean] def fifty_move? halfmove >= 100 end # The terminal status of this position: :checkmate, :stalemate, or :draw # (insufficient material or 50-move rule). nil if the position is still # in progress. Threefold repetition requires game history and is # answered by {PGN::Game#outcome}. # # @return [Symbol, nil] def outcome return :checkmate if checkmate? return :stalemate if stalemate? return :draw if insufficient_material? || fifty_move? nil end # @return [PGN::FEN] a {PGN::FEN} object representing the current position # def to_fen PGN::FEN.from_attributes( board: board, active: player == :white ? 'w' : 'b', castling: castling.join, en_passant: en_passant, halfmove: halfmove.to_s, fullmove: fullmove.to_s ) end # Positions are equal when their board, side to move, castling rights, # and en-passant square match. Halfmove/fullmove counters are ignored # (matching threefold-repetition semantics). def eql?(other) other.is_a?(PGN::Position) && player == other.player && castling == other.castling && en_passant == other.en_passant && zobrist == other.zobrist && board == other.board end alias == eql? def hash zobrist end # The Zobrist hash of the position. Computed lazily on first access and # cached, so the replay hot path (which never asks for the hash) pays # nothing; consumers like threefold-repetition checks pay one full seed. # # @return [Integer] def zobrist @zobrist ||= Zobrist.seed(board, player, castling, en_passant) end private # True when +move+ looks like a UCI coordinate string ("e2e4", # "e1g1", "a7a8q"), so it can be handed straight to the engine. def uci?(move) move.is_a?(String) && move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/) end # Convert a UCI string to SAN using the current position, for # {#legal_moves_san}. Promotion (if present) is passed as the letter. def uci_to_san(uci) from = uci[0, 2] to = uci[2, 2] promo = uci[4] PGN::Notation.san(self, from, to, promo) end # Map a castling side letter from {PGN::Move#castle} to the king's # from/to UCI squares for the side to move. CASTLE_UCI = { 'K' => 'e1g1', 'Q' => 'e1c1', 'k' => 'e8g8', 'q' => 'e8c8' }.freeze private_constant :CASTLE_UCI # Does the legal UCI move +uci+ match the parsed SAN +move+? A move # matches when destination, piece, promotion, and castling all agree, # and the origin square satisfies +move+'s disambiguation (if any). def matches_san?(uci, move) return uci == CASTLE_UCI[move.castle] if move.castle to = uci[2, 2] return false if move.destination != to from_idx = board.index_of(uci[0, 2]) return false if board.at_index(from_idx) != move.piece promo = uci[4] return false if move.promotion&.downcase != promo disambiguation_matches?(move.disambiguation, from_idx) end # Whether the disambiguation string from SAN (a file, a rank, or a full # square) describes the origin square at +from_idx+. nil disambiguation # matches any origin (ambiguity is handled by the caller counting matches). def disambiguation_matches?(disambiguation, from_idx) return true if disambiguation.nil? || disambiguation.empty? file = Board::INDEX_TO_FILE[from_idx & 0x0F] rank = Board::INDEX_TO_RANK[from_idx >> 4] case disambiguation when /\A[a-h]\z/ then file == disambiguation when /\A[1-8]\z/ then rank == disambiguation else file + rank == disambiguation end end # Whether +non_king+ is all bishops on squares of the same color (a known # insufficient-material draw, e.g. KB vs KB with same-colored bishops). def bishops_same_color?(non_king) return false unless non_king.all? { |piece, _| piece.upcase == 'B' } return false if non_king.size > 2 colors = non_king.map { |_piece, idx| ((idx & 0x0F) + (idx >> 4)) % 2 } colors.uniq.size == 1 end end |
#en_passant ⇒ String
Returns the en passant square if applicable.
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 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 |
# File 'lib/pgn/position.rb', line 32 class Position PLAYERS = %i[white black].freeze CASTLING = %w[K Q k q].freeze attr_accessor :board, :player, :castling, :en_passant, :halfmove, :fullmove # @return [PGN::Position] the starting position of a chess game # def self.start PGN::Position.new( PGN::Board.start, PLAYERS.first ) end # @param board [PGN::Board] the board for the position # @param player [Symbol] the player who moves next # @param castling [Array<String>] the castling moves that are still # available # @param en_passant [String, nil] the en passant square if applicable # @param halfmove [Integer] the number of halfmoves since the last pawn # move or capture # @param fullmove [Integer] the number of fullmoves made so far # # @example # PGN::Position.new( # PGN::Board.start, # :white, # ) # def initialize(board, player, castling = CASTLING, en_passant = nil, halfmove = 0, fullmove = 1) self.board = board self.player = player self.castling = castling self.en_passant = en_passant self.halfmove = halfmove self.fullmove = fullmove end # @param str [String] the move to make in SAN # @return [PGN::Position] the resulting position # # @example # queens_pawn = PGN::Position.start.move("d4") # def move(str) move = PGN::Move.new(str, player) calculator = PGN::MoveCalculator.new(board, move) restrictions = calculator.castling_restrictions new_castling = restrictions.empty? ? castling : castling - restrictions new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0 new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove no_move = str == '--' PGN::Position.new( no_move ? board : calculator.result_board, next_player, new_castling, calculator.en_passant_square, new_halfmove, new_fullmove ) end # @return [Symbol] the next player to move # def next_player player == :white ? :black : :white end # The perft node count at +depth+ from this position, computed by the # native bitboard engine via a FEN round-trip. Requires the compiled # native extension (the shipped gem); raises NameError if it is absent. # # @param depth [Integer] search depth, >= 0 # @return [Integer] # def perft(depth) raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0 PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth) end # All legal moves from this position as sorted UCI strings # (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed # by the native bitboard engine via a FEN round-trip. Requires the # compiled native extension; raises NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves end # All legal moves from this position as sorted SAN strings, computed # by delegating the native engine's UCI move list through # {PGN::Notation.san}. Requires the compiled native extension; raises # NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves_san engine = PGN::Bitboard::Engine.new(to_fen.to_s) engine.legal_moves.map { |uci| uci_to_san(uci) }.sort end # Whether +move+ is legal. Accepts SAN ("Nf3", "e4", "O-O", "a8=Q", # "Qxf7#") or UCI ("g1f3", "e2e4", "e1g1", "a7a8q"). Requires the # compiled native extension; raises NameError if it is absent. # # UCI is handed straight to the engine. SAN is resolved against the # engine's legal move list: a SAN string is legal only when it points # at exactly one legal move (so an ambiguous "Nd2" with two knights # is rejected, while "Nbd2"/"Nfd2" are accepted). The engine remains # the single source of truth for legality (king safety, pins, etc.). # # @param move [String] SAN or UCI # @return [Boolean] def legal?(move) engine = PGN::Bitboard::Engine.new(to_fen.to_s) return engine.legal?(move) if uci?(move) parsed = PGN::Move.new(move, player) return false if parsed.destination.nil? && parsed.castle.nil? candidates = engine.legal_moves.select { |uci| matches_san?(uci, parsed) } candidates.size == 1 rescue StandardError false end def inspect "\n#{board.inspect}" end # The color string for the side to move ('w'/'b'). def mover_color player == :white ? 'w' : 'b' end # The color string for the opponent of the side to move ('w'/'b'). def opponent_color player == :white ? 'b' : 'w' end # Whether the side to move's king is currently in check. # # @return [Boolean] def in_check? king = PGN::Attack.king_idx(board, mover_color) !king.nil? && PGN::Attack.attacked?(board, king, opponent_color) end # The algebraic squares of every piece of the given color that attacks # +square+ (algebraic, e.g. "e4"). Defaults to the side to move's # opponent, which is useful for check detection. # # @param square [String] e.g. "e4" # @param color [String, nil] 'w' or 'b'; defaults to the opponent color # @return [Array<String>] squares attacking +square+ def attackers(square, color = opponent_color) PGN::Attack.attackers(board, board.index_of(square), color) end # Whether the side to move has been checkmated (in check and no legal # move). Requires the native extension for legal-move enumeration. # # @return [Boolean] def checkmate? in_check? && legal_moves.empty? end # Whether the side to move has been stalemated (not in check and no # legal move). Requires the native extension. # # @return [Boolean] def stalemate? !in_check? && legal_moves.empty? end # Whether the position has insufficient material to mate. Covers K vs K, # K + one minor vs K, and same-colored bishops only. # # @return [Boolean] def insufficient_material? non_king = (0...128).each_with_object([]) do |idx, acc| next if idx.anybits?(0x88) piece = board.at_index(idx) acc << [piece, idx] if piece && piece.upcase != 'K' end return true if non_king.empty? return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase) bishops_same_color?(non_king) end # Whether the 50-move rule applies (100 halfmoves since the last pawn # move or capture). # # @return [Boolean] def fifty_move? halfmove >= 100 end # The terminal status of this position: :checkmate, :stalemate, or :draw # (insufficient material or 50-move rule). nil if the position is still # in progress. Threefold repetition requires game history and is # answered by {PGN::Game#outcome}. # # @return [Symbol, nil] def outcome return :checkmate if checkmate? return :stalemate if stalemate? return :draw if insufficient_material? || fifty_move? nil end # @return [PGN::FEN] a {PGN::FEN} object representing the current position # def to_fen PGN::FEN.from_attributes( board: board, active: player == :white ? 'w' : 'b', castling: castling.join, en_passant: en_passant, halfmove: halfmove.to_s, fullmove: fullmove.to_s ) end # Positions are equal when their board, side to move, castling rights, # and en-passant square match. Halfmove/fullmove counters are ignored # (matching threefold-repetition semantics). def eql?(other) other.is_a?(PGN::Position) && player == other.player && castling == other.castling && en_passant == other.en_passant && zobrist == other.zobrist && board == other.board end alias == eql? def hash zobrist end # The Zobrist hash of the position. Computed lazily on first access and # cached, so the replay hot path (which never asks for the hash) pays # nothing; consumers like threefold-repetition checks pay one full seed. # # @return [Integer] def zobrist @zobrist ||= Zobrist.seed(board, player, castling, en_passant) end private # True when +move+ looks like a UCI coordinate string ("e2e4", # "e1g1", "a7a8q"), so it can be handed straight to the engine. def uci?(move) move.is_a?(String) && move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/) end # Convert a UCI string to SAN using the current position, for # {#legal_moves_san}. Promotion (if present) is passed as the letter. def uci_to_san(uci) from = uci[0, 2] to = uci[2, 2] promo = uci[4] PGN::Notation.san(self, from, to, promo) end # Map a castling side letter from {PGN::Move#castle} to the king's # from/to UCI squares for the side to move. CASTLE_UCI = { 'K' => 'e1g1', 'Q' => 'e1c1', 'k' => 'e8g8', 'q' => 'e8c8' }.freeze private_constant :CASTLE_UCI # Does the legal UCI move +uci+ match the parsed SAN +move+? A move # matches when destination, piece, promotion, and castling all agree, # and the origin square satisfies +move+'s disambiguation (if any). def matches_san?(uci, move) return uci == CASTLE_UCI[move.castle] if move.castle to = uci[2, 2] return false if move.destination != to from_idx = board.index_of(uci[0, 2]) return false if board.at_index(from_idx) != move.piece promo = uci[4] return false if move.promotion&.downcase != promo disambiguation_matches?(move.disambiguation, from_idx) end # Whether the disambiguation string from SAN (a file, a rank, or a full # square) describes the origin square at +from_idx+. nil disambiguation # matches any origin (ambiguity is handled by the caller counting matches). def disambiguation_matches?(disambiguation, from_idx) return true if disambiguation.nil? || disambiguation.empty? file = Board::INDEX_TO_FILE[from_idx & 0x0F] rank = Board::INDEX_TO_RANK[from_idx >> 4] case disambiguation when /\A[a-h]\z/ then file == disambiguation when /\A[1-8]\z/ then rank == disambiguation else file + rank == disambiguation end end # Whether +non_king+ is all bishops on squares of the same color (a known # insufficient-material draw, e.g. KB vs KB with same-colored bishops). def bishops_same_color?(non_king) return false unless non_king.all? { |piece, _| piece.upcase == 'B' } return false if non_king.size > 2 colors = non_king.map { |_piece, idx| ((idx & 0x0F) + (idx >> 4)) % 2 } colors.uniq.size == 1 end end |
#fullmove ⇒ Integer
Returns the number of fullmoves made so far.
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 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 |
# File 'lib/pgn/position.rb', line 32 class Position PLAYERS = %i[white black].freeze CASTLING = %w[K Q k q].freeze attr_accessor :board, :player, :castling, :en_passant, :halfmove, :fullmove # @return [PGN::Position] the starting position of a chess game # def self.start PGN::Position.new( PGN::Board.start, PLAYERS.first ) end # @param board [PGN::Board] the board for the position # @param player [Symbol] the player who moves next # @param castling [Array<String>] the castling moves that are still # available # @param en_passant [String, nil] the en passant square if applicable # @param halfmove [Integer] the number of halfmoves since the last pawn # move or capture # @param fullmove [Integer] the number of fullmoves made so far # # @example # PGN::Position.new( # PGN::Board.start, # :white, # ) # def initialize(board, player, castling = CASTLING, en_passant = nil, halfmove = 0, fullmove = 1) self.board = board self.player = player self.castling = castling self.en_passant = en_passant self.halfmove = halfmove self.fullmove = fullmove end # @param str [String] the move to make in SAN # @return [PGN::Position] the resulting position # # @example # queens_pawn = PGN::Position.start.move("d4") # def move(str) move = PGN::Move.new(str, player) calculator = PGN::MoveCalculator.new(board, move) restrictions = calculator.castling_restrictions new_castling = restrictions.empty? ? castling : castling - restrictions new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0 new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove no_move = str == '--' PGN::Position.new( no_move ? board : calculator.result_board, next_player, new_castling, calculator.en_passant_square, new_halfmove, new_fullmove ) end # @return [Symbol] the next player to move # def next_player player == :white ? :black : :white end # The perft node count at +depth+ from this position, computed by the # native bitboard engine via a FEN round-trip. Requires the compiled # native extension (the shipped gem); raises NameError if it is absent. # # @param depth [Integer] search depth, >= 0 # @return [Integer] # def perft(depth) raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0 PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth) end # All legal moves from this position as sorted UCI strings # (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed # by the native bitboard engine via a FEN round-trip. Requires the # compiled native extension; raises NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves end # All legal moves from this position as sorted SAN strings, computed # by delegating the native engine's UCI move list through # {PGN::Notation.san}. Requires the compiled native extension; raises # NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves_san engine = PGN::Bitboard::Engine.new(to_fen.to_s) engine.legal_moves.map { |uci| uci_to_san(uci) }.sort end # Whether +move+ is legal. Accepts SAN ("Nf3", "e4", "O-O", "a8=Q", # "Qxf7#") or UCI ("g1f3", "e2e4", "e1g1", "a7a8q"). Requires the # compiled native extension; raises NameError if it is absent. # # UCI is handed straight to the engine. SAN is resolved against the # engine's legal move list: a SAN string is legal only when it points # at exactly one legal move (so an ambiguous "Nd2" with two knights # is rejected, while "Nbd2"/"Nfd2" are accepted). The engine remains # the single source of truth for legality (king safety, pins, etc.). # # @param move [String] SAN or UCI # @return [Boolean] def legal?(move) engine = PGN::Bitboard::Engine.new(to_fen.to_s) return engine.legal?(move) if uci?(move) parsed = PGN::Move.new(move, player) return false if parsed.destination.nil? && parsed.castle.nil? candidates = engine.legal_moves.select { |uci| matches_san?(uci, parsed) } candidates.size == 1 rescue StandardError false end def inspect "\n#{board.inspect}" end # The color string for the side to move ('w'/'b'). def mover_color player == :white ? 'w' : 'b' end # The color string for the opponent of the side to move ('w'/'b'). def opponent_color player == :white ? 'b' : 'w' end # Whether the side to move's king is currently in check. # # @return [Boolean] def in_check? king = PGN::Attack.king_idx(board, mover_color) !king.nil? && PGN::Attack.attacked?(board, king, opponent_color) end # The algebraic squares of every piece of the given color that attacks # +square+ (algebraic, e.g. "e4"). Defaults to the side to move's # opponent, which is useful for check detection. # # @param square [String] e.g. "e4" # @param color [String, nil] 'w' or 'b'; defaults to the opponent color # @return [Array<String>] squares attacking +square+ def attackers(square, color = opponent_color) PGN::Attack.attackers(board, board.index_of(square), color) end # Whether the side to move has been checkmated (in check and no legal # move). Requires the native extension for legal-move enumeration. # # @return [Boolean] def checkmate? in_check? && legal_moves.empty? end # Whether the side to move has been stalemated (not in check and no # legal move). Requires the native extension. # # @return [Boolean] def stalemate? !in_check? && legal_moves.empty? end # Whether the position has insufficient material to mate. Covers K vs K, # K + one minor vs K, and same-colored bishops only. # # @return [Boolean] def insufficient_material? non_king = (0...128).each_with_object([]) do |idx, acc| next if idx.anybits?(0x88) piece = board.at_index(idx) acc << [piece, idx] if piece && piece.upcase != 'K' end return true if non_king.empty? return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase) bishops_same_color?(non_king) end # Whether the 50-move rule applies (100 halfmoves since the last pawn # move or capture). # # @return [Boolean] def fifty_move? halfmove >= 100 end # The terminal status of this position: :checkmate, :stalemate, or :draw # (insufficient material or 50-move rule). nil if the position is still # in progress. Threefold repetition requires game history and is # answered by {PGN::Game#outcome}. # # @return [Symbol, nil] def outcome return :checkmate if checkmate? return :stalemate if stalemate? return :draw if insufficient_material? || fifty_move? nil end # @return [PGN::FEN] a {PGN::FEN} object representing the current position # def to_fen PGN::FEN.from_attributes( board: board, active: player == :white ? 'w' : 'b', castling: castling.join, en_passant: en_passant, halfmove: halfmove.to_s, fullmove: fullmove.to_s ) end # Positions are equal when their board, side to move, castling rights, # and en-passant square match. Halfmove/fullmove counters are ignored # (matching threefold-repetition semantics). def eql?(other) other.is_a?(PGN::Position) && player == other.player && castling == other.castling && en_passant == other.en_passant && zobrist == other.zobrist && board == other.board end alias == eql? def hash zobrist end # The Zobrist hash of the position. Computed lazily on first access and # cached, so the replay hot path (which never asks for the hash) pays # nothing; consumers like threefold-repetition checks pay one full seed. # # @return [Integer] def zobrist @zobrist ||= Zobrist.seed(board, player, castling, en_passant) end private # True when +move+ looks like a UCI coordinate string ("e2e4", # "e1g1", "a7a8q"), so it can be handed straight to the engine. def uci?(move) move.is_a?(String) && move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/) end # Convert a UCI string to SAN using the current position, for # {#legal_moves_san}. Promotion (if present) is passed as the letter. def uci_to_san(uci) from = uci[0, 2] to = uci[2, 2] promo = uci[4] PGN::Notation.san(self, from, to, promo) end # Map a castling side letter from {PGN::Move#castle} to the king's # from/to UCI squares for the side to move. CASTLE_UCI = { 'K' => 'e1g1', 'Q' => 'e1c1', 'k' => 'e8g8', 'q' => 'e8c8' }.freeze private_constant :CASTLE_UCI # Does the legal UCI move +uci+ match the parsed SAN +move+? A move # matches when destination, piece, promotion, and castling all agree, # and the origin square satisfies +move+'s disambiguation (if any). def matches_san?(uci, move) return uci == CASTLE_UCI[move.castle] if move.castle to = uci[2, 2] return false if move.destination != to from_idx = board.index_of(uci[0, 2]) return false if board.at_index(from_idx) != move.piece promo = uci[4] return false if move.promotion&.downcase != promo disambiguation_matches?(move.disambiguation, from_idx) end # Whether the disambiguation string from SAN (a file, a rank, or a full # square) describes the origin square at +from_idx+. nil disambiguation # matches any origin (ambiguity is handled by the caller counting matches). def disambiguation_matches?(disambiguation, from_idx) return true if disambiguation.nil? || disambiguation.empty? file = Board::INDEX_TO_FILE[from_idx & 0x0F] rank = Board::INDEX_TO_RANK[from_idx >> 4] case disambiguation when /\A[a-h]\z/ then file == disambiguation when /\A[1-8]\z/ then rank == disambiguation else file + rank == disambiguation end end # Whether +non_king+ is all bishops on squares of the same color (a known # insufficient-material draw, e.g. KB vs KB with same-colored bishops). def bishops_same_color?(non_king) return false unless non_king.all? { |piece, _| piece.upcase == 'B' } return false if non_king.size > 2 colors = non_king.map { |_piece, idx| ((idx & 0x0F) + (idx >> 4)) % 2 } colors.uniq.size == 1 end end |
#halfmove ⇒ Integer
Returns the number of halfmoves since the last pawn move or capture.
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 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 |
# File 'lib/pgn/position.rb', line 32 class Position PLAYERS = %i[white black].freeze CASTLING = %w[K Q k q].freeze attr_accessor :board, :player, :castling, :en_passant, :halfmove, :fullmove # @return [PGN::Position] the starting position of a chess game # def self.start PGN::Position.new( PGN::Board.start, PLAYERS.first ) end # @param board [PGN::Board] the board for the position # @param player [Symbol] the player who moves next # @param castling [Array<String>] the castling moves that are still # available # @param en_passant [String, nil] the en passant square if applicable # @param halfmove [Integer] the number of halfmoves since the last pawn # move or capture # @param fullmove [Integer] the number of fullmoves made so far # # @example # PGN::Position.new( # PGN::Board.start, # :white, # ) # def initialize(board, player, castling = CASTLING, en_passant = nil, halfmove = 0, fullmove = 1) self.board = board self.player = player self.castling = castling self.en_passant = en_passant self.halfmove = halfmove self.fullmove = fullmove end # @param str [String] the move to make in SAN # @return [PGN::Position] the resulting position # # @example # queens_pawn = PGN::Position.start.move("d4") # def move(str) move = PGN::Move.new(str, player) calculator = PGN::MoveCalculator.new(board, move) restrictions = calculator.castling_restrictions new_castling = restrictions.empty? ? castling : castling - restrictions new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0 new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove no_move = str == '--' PGN::Position.new( no_move ? board : calculator.result_board, next_player, new_castling, calculator.en_passant_square, new_halfmove, new_fullmove ) end # @return [Symbol] the next player to move # def next_player player == :white ? :black : :white end # The perft node count at +depth+ from this position, computed by the # native bitboard engine via a FEN round-trip. Requires the compiled # native extension (the shipped gem); raises NameError if it is absent. # # @param depth [Integer] search depth, >= 0 # @return [Integer] # def perft(depth) raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0 PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth) end # All legal moves from this position as sorted UCI strings # (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed # by the native bitboard engine via a FEN round-trip. Requires the # compiled native extension; raises NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves end # All legal moves from this position as sorted SAN strings, computed # by delegating the native engine's UCI move list through # {PGN::Notation.san}. Requires the compiled native extension; raises # NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves_san engine = PGN::Bitboard::Engine.new(to_fen.to_s) engine.legal_moves.map { |uci| uci_to_san(uci) }.sort end # Whether +move+ is legal. Accepts SAN ("Nf3", "e4", "O-O", "a8=Q", # "Qxf7#") or UCI ("g1f3", "e2e4", "e1g1", "a7a8q"). Requires the # compiled native extension; raises NameError if it is absent. # # UCI is handed straight to the engine. SAN is resolved against the # engine's legal move list: a SAN string is legal only when it points # at exactly one legal move (so an ambiguous "Nd2" with two knights # is rejected, while "Nbd2"/"Nfd2" are accepted). The engine remains # the single source of truth for legality (king safety, pins, etc.). # # @param move [String] SAN or UCI # @return [Boolean] def legal?(move) engine = PGN::Bitboard::Engine.new(to_fen.to_s) return engine.legal?(move) if uci?(move) parsed = PGN::Move.new(move, player) return false if parsed.destination.nil? && parsed.castle.nil? candidates = engine.legal_moves.select { |uci| matches_san?(uci, parsed) } candidates.size == 1 rescue StandardError false end def inspect "\n#{board.inspect}" end # The color string for the side to move ('w'/'b'). def mover_color player == :white ? 'w' : 'b' end # The color string for the opponent of the side to move ('w'/'b'). def opponent_color player == :white ? 'b' : 'w' end # Whether the side to move's king is currently in check. # # @return [Boolean] def in_check? king = PGN::Attack.king_idx(board, mover_color) !king.nil? && PGN::Attack.attacked?(board, king, opponent_color) end # The algebraic squares of every piece of the given color that attacks # +square+ (algebraic, e.g. "e4"). Defaults to the side to move's # opponent, which is useful for check detection. # # @param square [String] e.g. "e4" # @param color [String, nil] 'w' or 'b'; defaults to the opponent color # @return [Array<String>] squares attacking +square+ def attackers(square, color = opponent_color) PGN::Attack.attackers(board, board.index_of(square), color) end # Whether the side to move has been checkmated (in check and no legal # move). Requires the native extension for legal-move enumeration. # # @return [Boolean] def checkmate? in_check? && legal_moves.empty? end # Whether the side to move has been stalemated (not in check and no # legal move). Requires the native extension. # # @return [Boolean] def stalemate? !in_check? && legal_moves.empty? end # Whether the position has insufficient material to mate. Covers K vs K, # K + one minor vs K, and same-colored bishops only. # # @return [Boolean] def insufficient_material? non_king = (0...128).each_with_object([]) do |idx, acc| next if idx.anybits?(0x88) piece = board.at_index(idx) acc << [piece, idx] if piece && piece.upcase != 'K' end return true if non_king.empty? return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase) bishops_same_color?(non_king) end # Whether the 50-move rule applies (100 halfmoves since the last pawn # move or capture). # # @return [Boolean] def fifty_move? halfmove >= 100 end # The terminal status of this position: :checkmate, :stalemate, or :draw # (insufficient material or 50-move rule). nil if the position is still # in progress. Threefold repetition requires game history and is # answered by {PGN::Game#outcome}. # # @return [Symbol, nil] def outcome return :checkmate if checkmate? return :stalemate if stalemate? return :draw if insufficient_material? || fifty_move? nil end # @return [PGN::FEN] a {PGN::FEN} object representing the current position # def to_fen PGN::FEN.from_attributes( board: board, active: player == :white ? 'w' : 'b', castling: castling.join, en_passant: en_passant, halfmove: halfmove.to_s, fullmove: fullmove.to_s ) end # Positions are equal when their board, side to move, castling rights, # and en-passant square match. Halfmove/fullmove counters are ignored # (matching threefold-repetition semantics). def eql?(other) other.is_a?(PGN::Position) && player == other.player && castling == other.castling && en_passant == other.en_passant && zobrist == other.zobrist && board == other.board end alias == eql? def hash zobrist end # The Zobrist hash of the position. Computed lazily on first access and # cached, so the replay hot path (which never asks for the hash) pays # nothing; consumers like threefold-repetition checks pay one full seed. # # @return [Integer] def zobrist @zobrist ||= Zobrist.seed(board, player, castling, en_passant) end private # True when +move+ looks like a UCI coordinate string ("e2e4", # "e1g1", "a7a8q"), so it can be handed straight to the engine. def uci?(move) move.is_a?(String) && move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/) end # Convert a UCI string to SAN using the current position, for # {#legal_moves_san}. Promotion (if present) is passed as the letter. def uci_to_san(uci) from = uci[0, 2] to = uci[2, 2] promo = uci[4] PGN::Notation.san(self, from, to, promo) end # Map a castling side letter from {PGN::Move#castle} to the king's # from/to UCI squares for the side to move. CASTLE_UCI = { 'K' => 'e1g1', 'Q' => 'e1c1', 'k' => 'e8g8', 'q' => 'e8c8' }.freeze private_constant :CASTLE_UCI # Does the legal UCI move +uci+ match the parsed SAN +move+? A move # matches when destination, piece, promotion, and castling all agree, # and the origin square satisfies +move+'s disambiguation (if any). def matches_san?(uci, move) return uci == CASTLE_UCI[move.castle] if move.castle to = uci[2, 2] return false if move.destination != to from_idx = board.index_of(uci[0, 2]) return false if board.at_index(from_idx) != move.piece promo = uci[4] return false if move.promotion&.downcase != promo disambiguation_matches?(move.disambiguation, from_idx) end # Whether the disambiguation string from SAN (a file, a rank, or a full # square) describes the origin square at +from_idx+. nil disambiguation # matches any origin (ambiguity is handled by the caller counting matches). def disambiguation_matches?(disambiguation, from_idx) return true if disambiguation.nil? || disambiguation.empty? file = Board::INDEX_TO_FILE[from_idx & 0x0F] rank = Board::INDEX_TO_RANK[from_idx >> 4] case disambiguation when /\A[a-h]\z/ then file == disambiguation when /\A[1-8]\z/ then rank == disambiguation else file + rank == disambiguation end end # Whether +non_king+ is all bishops on squares of the same color (a known # insufficient-material draw, e.g. KB vs KB with same-colored bishops). def bishops_same_color?(non_king) return false unless non_king.all? { |piece, _| piece.upcase == 'B' } return false if non_king.size > 2 colors = non_king.map { |_piece, idx| ((idx & 0x0F) + (idx >> 4)) % 2 } colors.uniq.size == 1 end end |
#player ⇒ Symbol
Returns the player who moves next.
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 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 |
# File 'lib/pgn/position.rb', line 32 class Position PLAYERS = %i[white black].freeze CASTLING = %w[K Q k q].freeze attr_accessor :board, :player, :castling, :en_passant, :halfmove, :fullmove # @return [PGN::Position] the starting position of a chess game # def self.start PGN::Position.new( PGN::Board.start, PLAYERS.first ) end # @param board [PGN::Board] the board for the position # @param player [Symbol] the player who moves next # @param castling [Array<String>] the castling moves that are still # available # @param en_passant [String, nil] the en passant square if applicable # @param halfmove [Integer] the number of halfmoves since the last pawn # move or capture # @param fullmove [Integer] the number of fullmoves made so far # # @example # PGN::Position.new( # PGN::Board.start, # :white, # ) # def initialize(board, player, castling = CASTLING, en_passant = nil, halfmove = 0, fullmove = 1) self.board = board self.player = player self.castling = castling self.en_passant = en_passant self.halfmove = halfmove self.fullmove = fullmove end # @param str [String] the move to make in SAN # @return [PGN::Position] the resulting position # # @example # queens_pawn = PGN::Position.start.move("d4") # def move(str) move = PGN::Move.new(str, player) calculator = PGN::MoveCalculator.new(board, move) restrictions = calculator.castling_restrictions new_castling = restrictions.empty? ? castling : castling - restrictions new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0 new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove no_move = str == '--' PGN::Position.new( no_move ? board : calculator.result_board, next_player, new_castling, calculator.en_passant_square, new_halfmove, new_fullmove ) end # @return [Symbol] the next player to move # def next_player player == :white ? :black : :white end # The perft node count at +depth+ from this position, computed by the # native bitboard engine via a FEN round-trip. Requires the compiled # native extension (the shipped gem); raises NameError if it is absent. # # @param depth [Integer] search depth, >= 0 # @return [Integer] # def perft(depth) raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0 PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth) end # All legal moves from this position as sorted UCI strings # (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed # by the native bitboard engine via a FEN round-trip. Requires the # compiled native extension; raises NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves end # All legal moves from this position as sorted SAN strings, computed # by delegating the native engine's UCI move list through # {PGN::Notation.san}. Requires the compiled native extension; raises # NameError if it is absent. # # @return [Array<String>] sorted lexicographically # def legal_moves_san engine = PGN::Bitboard::Engine.new(to_fen.to_s) engine.legal_moves.map { |uci| uci_to_san(uci) }.sort end # Whether +move+ is legal. Accepts SAN ("Nf3", "e4", "O-O", "a8=Q", # "Qxf7#") or UCI ("g1f3", "e2e4", "e1g1", "a7a8q"). Requires the # compiled native extension; raises NameError if it is absent. # # UCI is handed straight to the engine. SAN is resolved against the # engine's legal move list: a SAN string is legal only when it points # at exactly one legal move (so an ambiguous "Nd2" with two knights # is rejected, while "Nbd2"/"Nfd2" are accepted). The engine remains # the single source of truth for legality (king safety, pins, etc.). # # @param move [String] SAN or UCI # @return [Boolean] def legal?(move) engine = PGN::Bitboard::Engine.new(to_fen.to_s) return engine.legal?(move) if uci?(move) parsed = PGN::Move.new(move, player) return false if parsed.destination.nil? && parsed.castle.nil? candidates = engine.legal_moves.select { |uci| matches_san?(uci, parsed) } candidates.size == 1 rescue StandardError false end def inspect "\n#{board.inspect}" end # The color string for the side to move ('w'/'b'). def mover_color player == :white ? 'w' : 'b' end # The color string for the opponent of the side to move ('w'/'b'). def opponent_color player == :white ? 'b' : 'w' end # Whether the side to move's king is currently in check. # # @return [Boolean] def in_check? king = PGN::Attack.king_idx(board, mover_color) !king.nil? && PGN::Attack.attacked?(board, king, opponent_color) end # The algebraic squares of every piece of the given color that attacks # +square+ (algebraic, e.g. "e4"). Defaults to the side to move's # opponent, which is useful for check detection. # # @param square [String] e.g. "e4" # @param color [String, nil] 'w' or 'b'; defaults to the opponent color # @return [Array<String>] squares attacking +square+ def attackers(square, color = opponent_color) PGN::Attack.attackers(board, board.index_of(square), color) end # Whether the side to move has been checkmated (in check and no legal # move). Requires the native extension for legal-move enumeration. # # @return [Boolean] def checkmate? in_check? && legal_moves.empty? end # Whether the side to move has been stalemated (not in check and no # legal move). Requires the native extension. # # @return [Boolean] def stalemate? !in_check? && legal_moves.empty? end # Whether the position has insufficient material to mate. Covers K vs K, # K + one minor vs K, and same-colored bishops only. # # @return [Boolean] def insufficient_material? non_king = (0...128).each_with_object([]) do |idx, acc| next if idx.anybits?(0x88) piece = board.at_index(idx) acc << [piece, idx] if piece && piece.upcase != 'K' end return true if non_king.empty? return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase) bishops_same_color?(non_king) end # Whether the 50-move rule applies (100 halfmoves since the last pawn # move or capture). # # @return [Boolean] def fifty_move? halfmove >= 100 end # The terminal status of this position: :checkmate, :stalemate, or :draw # (insufficient material or 50-move rule). nil if the position is still # in progress. Threefold repetition requires game history and is # answered by {PGN::Game#outcome}. # # @return [Symbol, nil] def outcome return :checkmate if checkmate? return :stalemate if stalemate? return :draw if insufficient_material? || fifty_move? nil end # @return [PGN::FEN] a {PGN::FEN} object representing the current position # def to_fen PGN::FEN.from_attributes( board: board, active: player == :white ? 'w' : 'b', castling: castling.join, en_passant: en_passant, halfmove: halfmove.to_s, fullmove: fullmove.to_s ) end # Positions are equal when their board, side to move, castling rights, # and en-passant square match. Halfmove/fullmove counters are ignored # (matching threefold-repetition semantics). def eql?(other) other.is_a?(PGN::Position) && player == other.player && castling == other.castling && en_passant == other.en_passant && zobrist == other.zobrist && board == other.board end alias == eql? def hash zobrist end # The Zobrist hash of the position. Computed lazily on first access and # cached, so the replay hot path (which never asks for the hash) pays # nothing; consumers like threefold-repetition checks pay one full seed. # # @return [Integer] def zobrist @zobrist ||= Zobrist.seed(board, player, castling, en_passant) end private # True when +move+ looks like a UCI coordinate string ("e2e4", # "e1g1", "a7a8q"), so it can be handed straight to the engine. def uci?(move) move.is_a?(String) && move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/) end # Convert a UCI string to SAN using the current position, for # {#legal_moves_san}. Promotion (if present) is passed as the letter. def uci_to_san(uci) from = uci[0, 2] to = uci[2, 2] promo = uci[4] PGN::Notation.san(self, from, to, promo) end # Map a castling side letter from {PGN::Move#castle} to the king's # from/to UCI squares for the side to move. CASTLE_UCI = { 'K' => 'e1g1', 'Q' => 'e1c1', 'k' => 'e8g8', 'q' => 'e8c8' }.freeze private_constant :CASTLE_UCI # Does the legal UCI move +uci+ match the parsed SAN +move+? A move # matches when destination, piece, promotion, and castling all agree, # and the origin square satisfies +move+'s disambiguation (if any). def matches_san?(uci, move) return uci == CASTLE_UCI[move.castle] if move.castle to = uci[2, 2] return false if move.destination != to from_idx = board.index_of(uci[0, 2]) return false if board.at_index(from_idx) != move.piece promo = uci[4] return false if move.promotion&.downcase != promo disambiguation_matches?(move.disambiguation, from_idx) end # Whether the disambiguation string from SAN (a file, a rank, or a full # square) describes the origin square at +from_idx+. nil disambiguation # matches any origin (ambiguity is handled by the caller counting matches). def disambiguation_matches?(disambiguation, from_idx) return true if disambiguation.nil? || disambiguation.empty? file = Board::INDEX_TO_FILE[from_idx & 0x0F] rank = Board::INDEX_TO_RANK[from_idx >> 4] case disambiguation when /\A[a-h]\z/ then file == disambiguation when /\A[1-8]\z/ then rank == disambiguation else file + rank == disambiguation end end # Whether +non_king+ is all bishops on squares of the same color (a known # insufficient-material draw, e.g. KB vs KB with same-colored bishops). def bishops_same_color?(non_king) return false unless non_king.all? { |piece, _| piece.upcase == 'B' } return false if non_king.size > 2 colors = non_king.map { |_piece, idx| ((idx & 0x0F) + (idx >> 4)) % 2 } colors.uniq.size == 1 end end |
Class Method Details
Instance Method Details
#attackers(square, color = opponent_color) ⇒ Array<String>
The algebraic squares of every piece of the given color that attacks
square (algebraic, e.g. "e4"). Defaults to the side to move's
opponent, which is useful for check detection.
193 194 195 |
# File 'lib/pgn/position.rb', line 193 def attackers(square, color = opponent_color) PGN::Attack.attackers(board, board.index_of(square), color) end |
#checkmate? ⇒ Boolean
Whether the side to move has been checkmated (in check and no legal move). Requires the native extension for legal-move enumeration.
201 202 203 |
# File 'lib/pgn/position.rb', line 201 def checkmate? in_check? && legal_moves.empty? end |
#eql?(other) ⇒ Boolean Also known as: ==
Positions are equal when their board, side to move, castling rights, and en-passant square match. Halfmove/fullmove counters are ignored (matching threefold-repetition semantics).
268 269 270 271 272 273 274 275 |
# File 'lib/pgn/position.rb', line 268 def eql?(other) other.is_a?(PGN::Position) && player == other.player && castling == other.castling && en_passant == other.en_passant && zobrist == other.zobrist && board == other.board end |
#fifty_move? ⇒ Boolean
Whether the 50-move rule applies (100 halfmoves since the last pawn move or capture).
234 235 236 |
# File 'lib/pgn/position.rb', line 234 def fifty_move? halfmove >= 100 end |
#hash ⇒ Object
279 280 281 |
# File 'lib/pgn/position.rb', line 279 def hash zobrist end |
#in_check? ⇒ Boolean
Whether the side to move's king is currently in check.
181 182 183 184 |
# File 'lib/pgn/position.rb', line 181 def in_check? king = PGN::Attack.king_idx(board, mover_color) !king.nil? && PGN::Attack.attacked?(board, king, opponent_color) end |
#inspect ⇒ Object
164 165 166 |
# File 'lib/pgn/position.rb', line 164 def inspect "\n#{board.inspect}" end |
#insufficient_material? ⇒ Boolean
Whether the position has insufficient material to mate. Covers K vs K, K + one minor vs K, and same-colored bishops only.
217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/pgn/position.rb', line 217 def insufficient_material? non_king = (0...128).each_with_object([]) do |idx, acc| next if idx.anybits?(0x88) piece = board.at_index(idx) acc << [piece, idx] if piece && piece.upcase != 'K' end return true if non_king.empty? return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase) bishops_same_color?(non_king) end |
#legal?(move) ⇒ Boolean
Whether move is legal. Accepts SAN ("Nf3", "e4", "O-O", "a8=Q",
"Qxf7#") or UCI ("g1f3", "e2e4", "e1g1", "a7a8q"). Requires the
compiled native extension; raises NameError if it is absent.
UCI is handed straight to the engine. SAN is resolved against the engine's legal move list: a SAN string is legal only when it points at exactly one legal move (so an ambiguous "Nd2" with two knights is rejected, while "Nbd2"/"Nfd2" are accepted). The engine remains the single source of truth for legality (king safety, pins, etc.).
151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/pgn/position.rb', line 151 def legal?(move) engine = PGN::Bitboard::Engine.new(to_fen.to_s) return engine.legal?(move) if uci?(move) parsed = PGN::Move.new(move, player) return false if parsed.destination.nil? && parsed.castle.nil? candidates = engine.legal_moves.select { |uci| matches_san?(uci, parsed) } candidates.size == 1 rescue StandardError false end |
#legal_moves ⇒ Array<String>
All legal moves from this position as sorted UCI strings (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed by the native bitboard engine via a FEN round-trip. Requires the compiled native extension; raises NameError if it is absent.
123 124 125 |
# File 'lib/pgn/position.rb', line 123 def legal_moves PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves end |
#legal_moves_san ⇒ Array<String>
All legal moves from this position as sorted SAN strings, computed by delegating the native engine's UCI move list through Notation.san. Requires the compiled native extension; raises NameError if it is absent.
134 135 136 137 |
# File 'lib/pgn/position.rb', line 134 def legal_moves_san engine = PGN::Bitboard::Engine.new(to_fen.to_s) engine.legal_moves.map { |uci| uci_to_san(uci) }.sort end |
#move(str) ⇒ PGN::Position
Returns the resulting position.
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 |
# File 'lib/pgn/position.rb', line 77 def move(str) move = PGN::Move.new(str, player) calculator = PGN::MoveCalculator.new(board, move) restrictions = calculator.castling_restrictions new_castling = restrictions.empty? ? castling : castling - restrictions new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0 new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove no_move = str == '--' PGN::Position.new( no_move ? board : calculator.result_board, next_player, new_castling, calculator.en_passant_square, new_halfmove, new_fullmove ) end |
#mover_color ⇒ Object
The color string for the side to move ('w'/'b').
169 170 171 |
# File 'lib/pgn/position.rb', line 169 def mover_color player == :white ? 'w' : 'b' end |
#next_player ⇒ Symbol
Returns the next player to move.
99 100 101 |
# File 'lib/pgn/position.rb', line 99 def next_player player == :white ? :black : :white end |
#opponent_color ⇒ Object
The color string for the opponent of the side to move ('w'/'b').
174 175 176 |
# File 'lib/pgn/position.rb', line 174 def opponent_color player == :white ? 'b' : 'w' end |
#outcome ⇒ Symbol?
The terminal status of this position: :checkmate, :stalemate, or :draw (insufficient material or 50-move rule). nil if the position is still in progress. Threefold repetition requires game history and is answered by Game#outcome.
244 245 246 247 248 249 250 |
# File 'lib/pgn/position.rb', line 244 def outcome return :checkmate if checkmate? return :stalemate if stalemate? return :draw if insufficient_material? || fifty_move? nil end |
#perft(depth) ⇒ Integer
The perft node count at depth from this position, computed by the
native bitboard engine via a FEN round-trip. Requires the compiled
native extension (the shipped gem); raises NameError if it is absent.
110 111 112 113 114 |
# File 'lib/pgn/position.rb', line 110 def perft(depth) raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0 PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth) end |
#stalemate? ⇒ Boolean
Whether the side to move has been stalemated (not in check and no legal move). Requires the native extension.
209 210 211 |
# File 'lib/pgn/position.rb', line 209 def stalemate? !in_check? && legal_moves.empty? end |
#to_fen ⇒ PGN::FEN
Returns a FEN object representing the current position.
254 255 256 257 258 259 260 261 262 263 |
# File 'lib/pgn/position.rb', line 254 def to_fen PGN::FEN.from_attributes( board: board, active: player == :white ? 'w' : 'b', castling: castling.join, en_passant: en_passant, halfmove: halfmove.to_s, fullmove: fullmove.to_s ) end |
#zobrist ⇒ Integer
The Zobrist hash of the position. Computed lazily on first access and cached, so the replay hot path (which never asks for the hash) pays nothing; consumers like threefold-repetition checks pay one full seed.
288 289 290 |
# File 'lib/pgn/position.rb', line 288 def zobrist @zobrist ||= Zobrist.seed(board, player, castling, en_passant) end |