Class: PGN::Position

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(board, player, castling = CASTLING, en_passant = nil, halfmove = 0, fullmove = 1) ⇒ Position

Returns a new instance of Position.

Examples:

PGN::Position.new(
  PGN::Board.start,
  :white,
)

Parameters:

  • board (PGN::Board)

    the board for the position

  • player (Symbol)

    the player who moves next

  • castling (Array<String>) (defaults to: CASTLING)

    the castling moves that are still available

  • en_passant (String, nil) (defaults to: nil)

    the en passant square if applicable

  • halfmove (Integer) (defaults to: 0)

    the number of halfmoves since the last pawn move or capture

  • fullmove (Integer) (defaults to: 1)

    the number of fullmoves made so far



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

#boardPGN::Board

Returns the board for the position.

Returns:



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

#castlingArray<String>

Returns the castling moves that are still available.

Examples:

position.castling #=> ["K", "k", "q"]

Returns:

  • (Array<String>)

    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_passantString

Returns the en passant square if applicable.

Returns:

  • (String)

    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

#fullmoveInteger

Returns the number of fullmoves made so far.

Returns:

  • (Integer)

    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

#halfmoveInteger

Returns the number of halfmoves since the last pawn move or capture.

Returns:

  • (Integer)

    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

#playerSymbol

Returns the player who moves next.

Examples:

position.player #=> :white

Returns:

  • (Symbol)

    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

.startPGN::Position

Returns the starting position of a chess game.

Returns:



40
41
42
43
44
45
# File 'lib/pgn/position.rb', line 40

def self.start
  PGN::Position.new(
    PGN::Board.start,
    PLAYERS.first
  )
end

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.

Parameters:

  • square (String)

    e.g. "e4"

  • color (String, nil) (defaults to: opponent_color)

    'w' or 'b'; defaults to the opponent color

Returns:

  • (Array<String>)

    squares attacking square



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.

Returns:

  • (Boolean)


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).

Returns:

  • (Boolean)


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).

Returns:

  • (Boolean)


234
235
236
# File 'lib/pgn/position.rb', line 234

def fifty_move?
  halfmove >= 100
end

#hashObject



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.

Returns:

  • (Boolean)


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

#inspectObject



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.

Returns:

  • (Boolean)


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.).

Parameters:

  • move (String)

    SAN or UCI

Returns:

  • (Boolean)


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

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.

Returns:

  • (Array<String>)

    sorted lexicographically



123
124
125
# File 'lib/pgn/position.rb', line 123

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 Notation.san. Requires the compiled native extension; raises NameError if it is absent.

Returns:

  • (Array<String>)

    sorted lexicographically



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.

Examples:

queens_pawn = PGN::Position.start.move("d4")

Parameters:

  • str (String)

    the move to make in SAN

Returns:



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_colorObject

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_playerSymbol

Returns the next player to move.

Returns:

  • (Symbol)

    the next player to move



99
100
101
# File 'lib/pgn/position.rb', line 99

def next_player
  player == :white ? :black : :white
end

#opponent_colorObject

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

#outcomeSymbol?

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.

Returns:

  • (Symbol, nil)


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.

Parameters:

  • depth (Integer)

    search depth, >= 0

Returns:

  • (Integer)

Raises:

  • (ArgumentError)


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.

Returns:

  • (Boolean)


209
210
211
# File 'lib/pgn/position.rb', line 209

def stalemate?
  !in_check? && legal_moves.empty?
end

#to_fenPGN::FEN

Returns a FEN object representing the current position.

Returns:

  • (PGN::FEN)

    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

#zobristInteger

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.

Returns:

  • (Integer)


288
289
290
# File 'lib/pgn/position.rb', line 288

def zobrist
  @zobrist ||= Zobrist.seed(board, player, castling, en_passant)
end