pgn2 1.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +34 -3
  3. data/.github/workflows/native.yml +32 -0
  4. data/.github/workflows/publish.yml +44 -2
  5. data/.github/workflows/release-gems.yml +40 -0
  6. data/.github/workflows/release.yml +52 -5
  7. data/.gitignore +9 -1
  8. data/.rubocop.yml +46 -6
  9. data/CHANGELOG.md +148 -0
  10. data/Gemfile +3 -0
  11. data/NOTICE.md +21 -0
  12. data/README.md +107 -5
  13. data/Rakefile +35 -10
  14. data/TODO.md +64 -0
  15. data/bench/baseline_moves.txt +38 -4
  16. data/bench/baseline_parse.txt +4 -4
  17. data/bench/cross_check.rb +61 -0
  18. data/bench/legal_moves.rb +38 -0
  19. data/bench/perft.rb +32 -0
  20. data/bench/profile_moves.rb +93 -0
  21. data/docs/superpowers/plans/2026-08-13-attack-masks-plan.md +51 -0
  22. data/docs/superpowers/plans/2026-08-13-perf-internals-plan.md +883 -0
  23. data/docs/superpowers/plans/2026-08-13-rust-bitboard-perft-plan.md +2442 -0
  24. data/docs/superpowers/plans/2026-08-14-chessie-migration.md +722 -0
  25. data/docs/superpowers/plans/2026-08-14-rust-integration-plan.md +444 -0
  26. data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
  27. data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
  28. data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
  29. data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
  30. data/ext/pgn2_native/Cargo.lock +321 -0
  31. data/ext/pgn2_native/Cargo.toml +19 -0
  32. data/ext/pgn2_native/extconf.rb +8 -0
  33. data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
  34. data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
  35. data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
  36. data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
  37. data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
  38. data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
  39. data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
  40. data/lib/pgn/bitboard.rb +13 -0
  41. data/lib/pgn/board.rb +64 -0
  42. data/lib/pgn/fen.rb +35 -46
  43. data/lib/pgn/game.rb +22 -13
  44. data/lib/pgn/move.rb +19 -15
  45. data/lib/pgn/move_calculator.rb +13 -1
  46. data/lib/pgn/notation.rb +24 -29
  47. data/lib/pgn/position.rb +60 -19
  48. data/lib/pgn/serializer.rb +13 -18
  49. data/lib/pgn/version.rb +1 -1
  50. data/lib/pgn/zobrist.rb +53 -0
  51. data/lib/pgn.rb +2 -0
  52. data/pgn2.gemspec +17 -10
  53. data/spec/bitboard_spec.rb +54 -0
  54. data/spec/board_spec.rb +53 -0
  55. data/spec/fen_spec.rb +65 -65
  56. data/spec/game_spec.rb +52 -15
  57. data/spec/lexer_spec.rb +5 -5
  58. data/spec/notation_spec.rb +5 -0
  59. data/spec/parser_spec.rb +8 -1
  60. data/spec/position_spec.rb +128 -27
  61. data/spec/serializer_spec.rb +4 -4
  62. data/spec/zobrist_spec.rb +46 -0
  63. metadata +97 -35
data/lib/pgn/board.rb CHANGED
@@ -32,6 +32,31 @@ module PGN
32
32
  RANK_TO_INDEX = ('1'..'8').each_with_index.to_h
33
33
  INDEX_TO_RANK = RANK_TO_INDEX.invert
34
34
 
35
+ # 0x88 knight offsets (a1 + 33 = b3, etc.).
36
+ KNIGHT_OFFS = [33, 31, -31, -33, 18, 14, -14, -18].freeze
37
+ KING_OFFS = [-1, 1, -16, 16, -15, 15, -17, 17].freeze
38
+
39
+ # Precomputed on-board attack masks: entry `idx` is the frozen Array of
40
+ # on-board 0x88 target indices reachable from `idx` by that piece. Built
41
+ # once at load time so the per-call offset + off-board test is replaced by
42
+ # a direct array iteration.
43
+ KNIGHT_ATTACKS = Array.new(128) do |idx|
44
+ next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate
45
+
46
+ KNIGHT_OFFS.each_with_object([]) do |off, a|
47
+ t = idx + off
48
+ a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate
49
+ end.freeze
50
+ end.freeze
51
+ KING_ATTACKS = Array.new(128) do |idx|
52
+ next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate
53
+
54
+ KING_OFFS.each_with_object([]) do |off, a|
55
+ t = idx + off
56
+ a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate
57
+ end.freeze
58
+ end.freeze
59
+
35
60
  # algebraic to unicode piece lookup
36
61
  #
37
62
  UNICODE_PIECES = {
@@ -264,6 +289,45 @@ module PGN
264
289
  INDEX_TO_FILE[idx & 0x0F] + INDEX_TO_RANK[idx >> 4]
265
290
  end
266
291
 
292
+ # Serializes the board to the FEN board-string portion (ranks 8→1,
293
+ # files a→h, runs of empty squares collapsed to a digit) by walking
294
+ # the 0x88 `@cells` array directly. This avoids rebuilding the 8x8
295
+ # `squares` array on every FEN generation.
296
+ #
297
+ # @return [String] e.g. "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
298
+ def fen_board_string
299
+ rows = []
300
+ 7.downto(0) do |rank|
301
+ s = String.new
302
+ run = 0
303
+ 0.upto(7) do |file|
304
+ piece = @cells[(rank * 16) + file]
305
+ if piece.nil?
306
+ run += 1
307
+ else
308
+ s << run.to_s if run.positive?
309
+ run = 0
310
+ s << piece
311
+ end
312
+ end
313
+ s << run.to_s if run.positive?
314
+ rows << s
315
+ end
316
+ rows.join('/')
317
+ end
318
+
319
+ # Boards are equal when every cell holds the same piece (or is
320
+ # empty), including off-board padding, which is always nil on both.
321
+ def eql?(other)
322
+ other.is_a?(Board) && cells == other.cells
323
+ end
324
+
325
+ alias == eql?
326
+
327
+ protected
328
+
329
+ attr_reader :cells
330
+
267
331
  private
268
332
 
269
333
  def file_of(square)
data/lib/pgn/fen.rb CHANGED
@@ -37,9 +37,13 @@ module PGN
37
37
  class FEN
38
38
  # The FEN string representing the starting position in chess
39
39
  #
40
- INITIAL = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
40
+ INITIAL = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'.freeze
41
41
 
42
- attr_accessor :board, :active, :castling, :en_passant, :halfmove, :fullmove
42
+ attr_accessor :board, :active, :halfmove, :fullmove
43
+ # `castling` and `en_passant` have custom writers below (normalize nil/empty
44
+ # to '-'), so expose readers here and define the writers explicitly to avoid
45
+ # redefining the accessors generated by attr_accessor (Lint/DuplicateMethods).
46
+ attr_reader :castling, :en_passant
43
47
 
44
48
  # @return [PGN::FEN] a {PGN::FEN} object representing the starting
45
49
  # position
@@ -61,22 +65,22 @@ module PGN
61
65
  # @param fen_string [String] a string in Forsyth-Edwards Notation
62
66
  #
63
67
  def initialize(fen_string = nil)
64
- if fen_string
65
- self.board_string,
66
- self.active,
67
- self.castling,
68
- self.en_passant,
69
- self.halfmove,
70
- self.fullmove = fen_string.split
71
- end
68
+ return unless fen_string
69
+
70
+ self.board_string,
71
+ self.active,
72
+ self.castling,
73
+ self.en_passant,
74
+ self.halfmove,
75
+ self.fullmove = fen_string.split
72
76
  end
73
77
 
74
78
  def en_passant=(val)
75
- @en_passant = val.nil? ? "-" : val
79
+ @en_passant = val.nil? ? '-' : val
76
80
  end
77
81
 
78
82
  def castling=(val)
79
- @castling = (val.nil? || val.empty?) ? "-" : val
83
+ @castling = val.nil? || val.empty? ? '-' : val
80
84
  end
81
85
 
82
86
  # @param board_fen [String] the fen representation of the board
@@ -84,10 +88,10 @@ module PGN
84
88
  # fen.board_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
85
89
  #
86
90
  def board_string=(board_fen)
87
- squares = board_fen.gsub(/\d/) {|match| "_" * match.to_i }
88
- .split("/")
89
- .map {|row| row.split('') }
90
- .map {|row| row.map {|e| e == "_" ? nil : e } }
91
+ squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
92
+ .split('/')
93
+ .map(&:chars)
94
+ .map { |row| row.map { |e| e == '_' ? nil : e } }
91
95
  .reverse
92
96
  .transpose
93
97
  self.board = PGN::Board.new(squares)
@@ -98,39 +102,24 @@ module PGN
98
102
  # PGN::FEN.start.board_string #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
99
103
  #
100
104
  def board_string
101
- rows = self.board.squares.transpose.reverse
102
- rows.map do |row|
103
- s = +""
104
- run = 0
105
- row.each do |e|
106
- if e.nil?
107
- run += 1
108
- else
109
- s << run.to_s if run > 0
110
- run = 0
111
- s << e
112
- end
113
- end
114
- s << run.to_s if run > 0
115
- s
116
- end.join("/")
105
+ board.fen_board_string
117
106
  end
118
107
 
119
108
  # @return [PGN::Position] a {PGN::Position} representing the current
120
109
  # position
121
110
  #
122
111
  def to_position
123
- player = self.active == 'w' ? :white : :black
124
- castling = self.castling.split('') - ['-']
125
- en_passant = self.en_passant == '-' ? nil : en_passant
112
+ player = active == 'w' ? :white : :black
113
+ castling = self.castling.chars - ['-']
114
+ en_passant = nil if self.en_passant == '-'
126
115
 
127
116
  PGN::Position.new(
128
- self.board,
117
+ board,
129
118
  player,
130
119
  castling,
131
120
  en_passant,
132
- self.halfmove.to_i,
133
- self.fullmove.to_i,
121
+ halfmove.to_i,
122
+ fullmove.to_i
134
123
  )
135
124
  end
136
125
 
@@ -140,17 +129,17 @@ module PGN
140
129
  #
141
130
  def to_s
142
131
  [
143
- self.board_string,
144
- self.active,
145
- self.castling,
146
- self.en_passant,
147
- self.halfmove,
148
- self.fullmove,
149
- ].join(" ")
132
+ board_string,
133
+ active,
134
+ castling,
135
+ en_passant,
136
+ halfmove,
137
+ fullmove
138
+ ].join(' ')
150
139
  end
151
140
 
152
141
  def inspect
153
- self.to_s
142
+ to_s
154
143
  end
155
144
  end
156
145
  end
data/lib/pgn/game.rb CHANGED
@@ -30,7 +30,9 @@ module PGN
30
30
  end
31
31
 
32
32
  def clean_text(text)
33
- text&.gsub(/{(.*)}/, '\1')&.gsub(/\s+/, ' ')&.strip
33
+ return unless text
34
+
35
+ text.gsub(/{(.*)}/, '\1').gsub(/\s+/, ' ').strip
34
36
  end
35
37
  end
36
38
 
@@ -106,16 +108,25 @@ module PGN
106
108
  # @return [Array<PGN::Position>] list of the {PGN::Position}s in the game
107
109
  #
108
110
  def positions
109
- @positions ||= begin
110
- position = starting_position
111
- arr = [position]
112
- moves.each do |move|
113
- new_pos = position.move(move.notation)
114
- arr << new_pos
115
- position = new_pos
116
- end
117
- arr
111
+ @positions ||= each_position.to_a
112
+ end
113
+
114
+ # @return [Enumerator, self] with a block: yields each {PGN::Position}
115
+ # in order (starting position, then one per move) and returns self.
116
+ # Without a block: returns an Enumerator that yields the same.
117
+ #
118
+ # The replay loop is shared with {#positions} so eager and lazy paths
119
+ # produce identical position objects in identical order.
120
+ def each_position
121
+ return enum_for(:each_position) unless block_given?
122
+
123
+ position = starting_position
124
+ yield position
125
+ moves.each do |move|
126
+ position = position.move(move.notation)
127
+ yield position
118
128
  end
129
+ self
119
130
  end
120
131
 
121
132
  # @return [Array<String>] list of the fen representations of the positions
@@ -158,9 +169,7 @@ module PGN
158
169
  return MoveText.new(entry.include?('0') ? entry.gsub('0', 'O') : entry) if entry.is_a?(String)
159
170
 
160
171
  notation = entry.notation.include?('0') ? entry.notation.gsub('0', 'O') : entry.notation
161
- if notation.equal?(entry.notation) && (entry.comment.nil? || !entry.comment.include?('{'))
162
- return entry
163
- end
172
+ return entry if notation.equal?(entry.notation) && (entry.comment.nil? || !entry.comment.include?('{'))
164
173
 
165
174
  MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
166
175
  end
data/lib/pgn/move.rb CHANGED
@@ -57,8 +57,12 @@ module PGN
57
57
  #
58
58
 
59
59
  class Move
60
- attr_accessor :san, :player
61
- attr_accessor :piece, :destination, :promotion, :check, :capture, :disambiguation, :castle
60
+ attr_accessor :san, :player, :destination, :check
61
+ # piece/promotion/capture/disambiguation/castle have custom writers below
62
+ # (color normalization, nil-coalescing, etc.), so expose only readers here
63
+ # to avoid redefining attr_accessor setters (Lint/DuplicateMethods).
64
+ attr_reader :piece, :promotion, :capture, :disambiguation, :castle
65
+
62
66
  # A regular expression for matching moves in standard algebraic
63
67
  # notation
64
68
  #
@@ -70,7 +74,7 @@ module PGN
70
74
  (?<capture> x ){0}
71
75
  (?<disambiguation> [a-h]?[1-8]? ){0}
72
76
 
73
- (?<castle> O-O(-O)? ){0}
77
+ (?<castle> O-O(?:-O)? ){0}
74
78
 
75
79
  (?<normal>
76
80
  \g<piece>?
@@ -80,8 +84,8 @@ module PGN
80
84
  \g<promotion>?
81
85
  ){0}
82
86
 
83
- \A (\g<castle> | \g<normal>) \g<check>? \z
84
- /x.freeze
87
+ \A (?:\g<castle> | \g<normal>) \g<check>? \z
88
+ /x
85
89
 
86
90
  # @param move [String] the move in SAN
87
91
  # @param player [Symbol] the player making the move
@@ -115,10 +119,10 @@ module PGN
115
119
  end
116
120
 
117
121
  def promotion=(val)
118
- if val
119
- val.downcase! if black?
120
- @promotion = val.delete('=')
121
- end
122
+ return unless val
123
+
124
+ val.downcase! if black?
125
+ @promotion = val.delete('=')
122
126
  end
123
127
 
124
128
  def capture=(val)
@@ -130,11 +134,11 @@ module PGN
130
134
  end
131
135
 
132
136
  def castle=(val)
133
- if val
134
- @castle = 'K' if val == 'O-O'
135
- @castle = 'Q' if val == 'O-O-O'
136
- @castle.downcase! if black?
137
- end
137
+ return unless val
138
+
139
+ @castle = 'K' if val == 'O-O'
140
+ @castle = 'Q' if val == 'O-O-O'
141
+ @castle.downcase! if black?
138
142
  end
139
143
 
140
144
  # @return [Boolean] whether the move results in check
@@ -164,7 +168,7 @@ module PGN
164
168
  # @return [Boolean] whether the piece being moved is a pawn
165
169
  #
166
170
  def pawn?
167
- piece == 'P' || piece == 'p'
171
+ %w[P p].include?(piece)
168
172
  end
169
173
  end
170
174
  end
@@ -191,7 +191,7 @@ module PGN
191
191
 
192
192
  possibilities = case move.piece
193
193
  when 'B', 'R', 'Q', 'b', 'r', 'q' then direction_origins
194
- when 'K', 'N', 'k', 'n' then move_origins
194
+ when 'K', 'N', 'k', 'n' then leaper_origins
195
195
  when 'P', 'p' then pawn_origins
196
196
  else # don't care move, used in variations
197
197
  return nil
@@ -237,6 +237,18 @@ module PGN
237
237
  possibilities
238
238
  end
239
239
 
240
+ # Knight/king origins use the precomputed on-board attack masks on
241
+ # {PGN::Board}, skipping the per-call off-board test.
242
+ def leaper_origins
243
+ dest = dest_idx
244
+ mask = move.piece.upcase == 'N' ? Board::KNIGHT_ATTACKS[dest] : Board::KING_ATTACKS[dest]
245
+ piece = move.piece
246
+
247
+ possibilities = []
248
+ mask.each { |t| possibilities << t if board.at_index(t) == piece }
249
+ possibilities
250
+ end
251
+
240
252
  # Computes the possible pawn origins based on the destination square
241
253
  # and whether or not the move is a capture.
242
254
  #
data/lib/pgn/notation.rb CHANGED
@@ -133,7 +133,7 @@ module PGN
133
133
  # Other squares holding `piece` whose move to `to_idx` is legal.
134
134
  def same_type_legals(piece, from_idx, to_idx)
135
135
  (0...128).each_with_object([]) do |idx, acc|
136
- next if (idx & 0x88) != 0
136
+ next if idx.anybits?(0x88)
137
137
  next if idx == from_idx
138
138
  next unless @board.at_index(idx) == piece
139
139
 
@@ -230,9 +230,9 @@ module PGN
230
230
 
231
231
  case piece.upcase
232
232
  when 'N'
233
- KNIGHT_OFFS.any? { |o| (from_idx + o) == to_idx }
233
+ Board::KNIGHT_ATTACKS[from_idx].include?(to_idx)
234
234
  when 'K'
235
- KING_OFFS.any? { |o| (from_idx + o) == to_idx }
235
+ Board::KING_ATTACKS[from_idx].include?(to_idx)
236
236
  when 'B', 'R', 'Q'
237
237
  slider_reaches?(piece.upcase, from_idx, to_idx)
238
238
  else
@@ -243,7 +243,7 @@ module PGN
243
243
  def slider_reaches?(piece_up, from_idx, to_idx)
244
244
  slider_dirs(piece_up).any? do |off|
245
245
  i = from_idx + off
246
- while (i & 0x88).zero?
246
+ while i.nobits?(0x88)
247
247
  return true if i == to_idx
248
248
  break if @board.at_index(i)
249
249
 
@@ -264,7 +264,7 @@ module PGN
264
264
  end
265
265
 
266
266
  def own?(piece, color)
267
- color == 'w' ? piece == piece.upcase : piece == piece.downcase
267
+ piece == (color == 'w' ? piece.upcase : piece.downcase)
268
268
  end
269
269
 
270
270
  # Slider ray directions for a piece letter ('B'/'R'/'Q').
@@ -281,7 +281,7 @@ module PGN
281
281
  def king_idx(board, color)
282
282
  king = color == 'w' ? 'K' : 'k'
283
283
  (0...128).each do |idx|
284
- next if (idx & 0x88) != 0
284
+ next if idx.anybits?(0x88)
285
285
 
286
286
  return idx if board.at_index(idx) == king
287
287
  end
@@ -301,24 +301,18 @@ module PGN
301
301
  pawn = color == 'w' ? 'P' : 'p'
302
302
  offs.any? do |off|
303
303
  i = target + off
304
- (i & 0x88).zero? && board.at_index(i) == pawn
304
+ i.nobits?(0x88) && board.at_index(i) == pawn
305
305
  end
306
306
  end
307
307
 
308
308
  def knight_attacked?(board, target, color)
309
309
  knight = color == 'w' ? 'N' : 'n'
310
- KNIGHT_OFFS.any? do |off|
311
- i = target + off
312
- (i & 0x88).zero? && board.at_index(i) == knight
313
- end
310
+ Board::KNIGHT_ATTACKS[target].any? { |i| board.at_index(i) == knight }
314
311
  end
315
312
 
316
313
  def king_attacked?(board, target, color)
317
314
  king = color == 'w' ? 'K' : 'k'
318
- KING_OFFS.any? do |off|
319
- i = target + off
320
- (i & 0x88).zero? && board.at_index(i) == king
321
- end
315
+ Board::KING_ATTACKS[target].any? { |i| board.at_index(i) == king }
322
316
  end
323
317
 
324
318
  def slider_attacked?(board, target, color)
@@ -332,10 +326,11 @@ module PGN
332
326
  def ray_hit?(board, target, dirs)
333
327
  dirs.each do |off|
334
328
  i = target + off
335
- while (i & 0x88).zero?
329
+ while i.nobits?(0x88)
336
330
  piece = board.at_index(i)
337
331
  if piece
338
332
  return true if yield(piece)
333
+
339
334
  break
340
335
  end
341
336
  i += off
@@ -352,7 +347,7 @@ module PGN
352
347
  ep_idx = ep_target_idx(position, board)
353
348
 
354
349
  (0...128).any? do |idx|
355
- next if (idx & 0x88) != 0
350
+ next if idx.anybits?(0x88)
356
351
 
357
352
  piece = board.at_index(idx)
358
353
  piece && own?(piece, color) && move_from?(board, idx, piece, color, ep_idx)
@@ -370,8 +365,8 @@ module PGN
370
365
  def move_from?(board, idx, piece, color, ep_idx)
371
366
  up = piece.upcase
372
367
  case up
373
- when 'N' then leaper_moves?(board, idx, piece, color, KNIGHT_OFFS)
374
- when 'K' then leaper_moves?(board, idx, piece, color, KING_OFFS)
368
+ when 'N' then leaper_moves?(board, idx, piece, color, Board::KNIGHT_ATTACKS[idx])
369
+ when 'K' then leaper_moves?(board, idx, piece, color, Board::KING_ATTACKS[idx])
375
370
  when 'B', 'R', 'Q'
376
371
  slider_moves?(board, idx, piece, color, slider_dirs(up))
377
372
  when 'P'
@@ -381,22 +376,22 @@ module PGN
381
376
  end
382
377
  end
383
378
 
384
- def leaper_moves?(board, idx, piece, color, offs)
385
- offs.any? do |off|
386
- t = idx + off
387
- (t & 0x88).zero? && landable?(board, t, color) && safe?(board, idx, t, piece, nil)
379
+ def leaper_moves?(board, idx, piece, color, targets)
380
+ targets.any? do |t|
381
+ landable?(board, t, color) && safe?(board, idx, t, piece, nil)
388
382
  end
389
383
  end
390
384
 
391
385
  def slider_moves?(board, idx, piece, color, dirs)
392
386
  dirs.any? do |off|
393
387
  t = idx + off
394
- while (t & 0x88).zero?
388
+ while t.nobits?(0x88)
395
389
  tp = board.at_index(t)
396
390
  if tp.nil?
397
391
  return true if safe?(board, idx, t, piece, nil)
398
392
  else
399
393
  return true if !own?(tp, color) && safe?(board, idx, t, piece, nil)
394
+
400
395
  break
401
396
  end
402
397
  t += off
@@ -416,24 +411,24 @@ module PGN
416
411
  promo_rank = color == 'w' ? 7 : 0
417
412
 
418
413
  t = idx + dir
419
- return false unless (t & 0x88).zero? && board.at_index(t).nil?
414
+ return false unless t.nobits?(0x88) && board.at_index(t).nil?
420
415
 
421
- promo = (t >> 4 == promo_rank) ? 'Q' : nil
416
+ promo = t >> 4 == promo_rank ? 'Q' : nil
422
417
  return true if safe?(board, idx, t, piece, promo)
423
418
 
424
419
  t2 = idx + (dir * 2)
425
420
  (idx >> 4) == start_rank && board.at_index(t2).nil? && safe?(board, idx, t2, piece, nil)
426
421
  end
427
422
 
428
- def pawn_captures?(board, idx, piece, color, ep_idx) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
423
+ def pawn_captures?(board, idx, piece, color, ep_idx)
429
424
  promo_rank = color == 'w' ? 7 : 0
430
425
  caps = color == 'w' ? [15, 17] : [-15, -17]
431
426
  caps.any? do |off|
432
427
  t = idx + off
433
- next false unless (t & 0x88).zero?
428
+ next false unless t.nobits?(0x88)
434
429
 
435
430
  tp = board.at_index(t)
436
- promo = (t >> 4 == promo_rank) ? 'Q' : nil
431
+ promo = t >> 4 == promo_rank ? 'Q' : nil
437
432
  if tp && !own?(tp, color)
438
433
  safe?(board, idx, t, piece, promo)
439
434
  elsif tp.nil? && t == ep_idx
data/lib/pgn/position.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module PGN
2
4
  # {PGN::Position} encapsulates all of the information necessary to
3
5
  # completely understand a chess position. It can be turned into a FEN string
@@ -31,12 +33,7 @@ module PGN
31
33
  PLAYERS = %i[white black].freeze
32
34
  CASTLING = %w[K Q k q].freeze
33
35
 
34
- attr_accessor :board
35
- attr_accessor :player
36
- attr_accessor :castling
37
- attr_accessor :en_passant
38
- attr_accessor :halfmove
39
- attr_accessor :fullmove
36
+ attr_accessor :board, :player, :castling, :en_passant, :halfmove, :fullmove
40
37
 
41
38
  # @return [PGN::Position] the starting position of a chess game
42
39
  #
@@ -83,17 +80,10 @@ module PGN
83
80
 
84
81
  restrictions = calculator.castling_restrictions
85
82
  new_castling = restrictions.empty? ? castling : castling - restrictions
86
- new_halfmove = if calculator.increment_halfmove?
87
- halfmove + 1
88
- else
89
- 0
90
- end
91
- new_fullmove = if calculator.increment_fullmove?
92
- fullmove + 1
93
- else
94
- fullmove
95
- end
96
- no_move = str == '--'
83
+ new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0
84
+ new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove
85
+ no_move = str == '--'
86
+
97
87
  PGN::Position.new(
98
88
  no_move ? board : calculator.result_board,
99
89
  next_player,
@@ -110,8 +100,32 @@ module PGN
110
100
  player == :white ? :black : :white
111
101
  end
112
102
 
103
+ # The perft node count at +depth+ from this position, computed by the
104
+ # native bitboard engine via a FEN round-trip. Requires the compiled
105
+ # native extension (the shipped gem); raises NameError if it is absent.
106
+ #
107
+ # @param depth [Integer] search depth, >= 0
108
+ # @return [Integer]
109
+ #
110
+ def perft(depth)
111
+ raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0
112
+
113
+ PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth)
114
+ end
115
+
116
+ # All legal moves from this position as sorted UCI strings
117
+ # (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed
118
+ # by the native bitboard engine via a FEN round-trip. Requires the
119
+ # compiled native extension; raises NameError if it is absent.
120
+ #
121
+ # @return [Array<String>] sorted lexicographically
122
+ #
123
+ def legal_moves
124
+ PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves
125
+ end
126
+
113
127
  def inspect
114
- "\n" + board.inspect
128
+ "\n#{board.inspect}"
115
129
  end
116
130
 
117
131
  # @return [PGN::FEN] a {PGN::FEN} object representing the current position
@@ -120,11 +134,38 @@ module PGN
120
134
  PGN::FEN.from_attributes(
121
135
  board: board,
122
136
  active: player == :white ? 'w' : 'b',
123
- castling: castling.join(''),
137
+ castling: castling.join,
124
138
  en_passant: en_passant,
125
139
  halfmove: halfmove.to_s,
126
140
  fullmove: fullmove.to_s
127
141
  )
128
142
  end
143
+
144
+ # Positions are equal when their board, side to move, castling rights,
145
+ # and en-passant square match. Halfmove/fullmove counters are ignored
146
+ # (matching threefold-repetition semantics).
147
+ def eql?(other)
148
+ other.is_a?(PGN::Position) &&
149
+ player == other.player &&
150
+ castling == other.castling &&
151
+ en_passant == other.en_passant &&
152
+ zobrist == other.zobrist &&
153
+ board == other.board
154
+ end
155
+
156
+ alias == eql?
157
+
158
+ def hash
159
+ zobrist
160
+ end
161
+
162
+ # The Zobrist hash of the position. Computed lazily on first access and
163
+ # cached, so the replay hot path (which never asks for the hash) pays
164
+ # nothing; consumers like threefold-repetition checks pay one full seed.
165
+ #
166
+ # @return [Integer]
167
+ def zobrist
168
+ @zobrist ||= Zobrist.seed(board, player, castling, en_passant)
169
+ end
129
170
  end
130
171
  end