pgn2 1.4.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 (64) 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 +183 -1
  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 +102 -31
  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 +103 -10
  42. data/lib/pgn/fen.rb +35 -46
  43. data/lib/pgn/game.rb +22 -13
  44. data/lib/pgn/lexer.rb +9 -6
  45. data/lib/pgn/move.rb +19 -15
  46. data/lib/pgn/move_calculator.rb +46 -20
  47. data/lib/pgn/notation.rb +24 -29
  48. data/lib/pgn/position.rb +60 -19
  49. data/lib/pgn/serializer.rb +13 -18
  50. data/lib/pgn/version.rb +1 -1
  51. data/lib/pgn/zobrist.rb +53 -0
  52. data/lib/pgn.rb +2 -0
  53. data/pgn2.gemspec +17 -10
  54. data/spec/bitboard_spec.rb +54 -0
  55. data/spec/board_spec.rb +53 -0
  56. data/spec/fen_spec.rb +65 -65
  57. data/spec/game_spec.rb +52 -15
  58. data/spec/lexer_spec.rb +5 -5
  59. data/spec/notation_spec.rb +5 -0
  60. data/spec/parser_spec.rb +8 -1
  61. data/spec/position_spec.rb +128 -27
  62. data/spec/serializer_spec.rb +4 -4
  63. data/spec/zobrist_spec.rb +46 -0
  64. metadata +101 -36
data/lib/pgn/board.rb CHANGED
@@ -27,10 +27,35 @@ module PGN
27
27
  ].freeze
28
28
 
29
29
  FILE_TO_INDEX = ('a'..'h').each_with_index.to_h
30
- INDEX_TO_FILE = FILE_TO_INDEX.map(&:reverse).to_h
30
+ INDEX_TO_FILE = FILE_TO_INDEX.invert
31
31
 
32
32
  RANK_TO_INDEX = ('1'..'8').each_with_index.to_h
33
- INDEX_TO_RANK = RANK_TO_INDEX.map(&:reverse).to_h
33
+ INDEX_TO_RANK = RANK_TO_INDEX.invert
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
34
59
 
35
60
  # algebraic to unicode piece lookup
36
61
  #
@@ -102,7 +127,6 @@ module PGN
102
127
  @cells[(r * 16) + f] = squares[f][r]
103
128
  end
104
129
  end
105
- @cells
106
130
  end
107
131
 
108
132
  # @overload at(str)
@@ -119,9 +143,9 @@ module PGN
119
143
  # board.at("e4") #=> "P"
120
144
  #
121
145
  def at(arg0, arg1 = nil)
122
- return @cells[(arg1 * 16) + arg0] unless arg1.nil?
146
+ return at_index(index_for(arg0, arg1)) unless arg1.nil?
123
147
 
124
- @cells[(rank_of(arg0) * 16) + file_of(arg0)]
148
+ at_index(index_of(arg0))
125
149
  end
126
150
 
127
151
  # @param changes [Hash<String, <String, nil>>] changes to make to the board
@@ -141,8 +165,7 @@ module PGN
141
165
  # board.update("e4", "P")
142
166
  #
143
167
  def update(square, piece)
144
- @cells[(rank_of(square) * 16) + file_of(square)] = piece
145
- self
168
+ update_index(index_of(square), piece)
146
169
  end
147
170
 
148
171
  # @param position [String] the square in algebraic notation
@@ -173,13 +196,25 @@ module PGN
173
196
  end.join("\n")
174
197
  end
175
198
 
199
+ # Build a {Board} directly from a 0x88 cell array, bypassing the 8x8
200
+ # -> 0x88 conversion in {#initialize}. Used by {#dup} (which runs every
201
+ # move) to skip the per-square rebuild; the cell array is already in the
202
+ # canonical 128-cell layout.
203
+ #
204
+ # @param cells [Array<String, nil>] a 128-cell 0x88 array
205
+ # @return [PGN::Board]
206
+ #
207
+ def self.from_cells(cells)
208
+ board = allocate
209
+ board.instance_variable_set(:@cells, cells)
210
+ board
211
+ end
212
+
176
213
  # @return [PGN::Board] a copy of self. Copies the 128-cell 0x88 array;
177
214
  # mutations to the copy do not affect the original.
178
215
  #
179
216
  def dup
180
- copy = PGN::Board.allocate
181
- copy.instance_variable_set(:@cells, @cells.dup)
182
- copy
217
+ self.class.from_cells(@cells.dup)
183
218
  end
184
219
 
185
220
  # -- 0x88 hot-path API (integer indices) ---------------------------------
@@ -235,6 +270,64 @@ module PGN
235
270
  self
236
271
  end
237
272
 
273
+ # Whether a 0x88 index is on the board (see the class doc for the
274
+ # bitmask this tests).
275
+ #
276
+ # @param idx [Integer] a 0x88 square index
277
+ # @return [Boolean]
278
+ #
279
+ def on_board?(idx)
280
+ (idx & 0x88).zero? # rubocop:disable Style/BitwisePredicate
281
+ end
282
+
283
+ # The algebraic square name of a 0x88 index.
284
+ #
285
+ # @param idx [Integer] a 0x88 square index
286
+ # @return [String] e.g. "e4"
287
+ #
288
+ def square_name(idx)
289
+ INDEX_TO_FILE[idx & 0x0F] + INDEX_TO_RANK[idx >> 4]
290
+ end
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
+
238
331
  private
239
332
 
240
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/lexer.rb CHANGED
@@ -161,7 +161,6 @@ module PGN
161
161
  def initialize(input)
162
162
  @input = input
163
163
  @ss = StringScanner.new(input)
164
- @line = 1
165
164
  @game_starts = []
166
165
  @between_games = true # at start we are "between" games
167
166
  end
@@ -182,7 +181,8 @@ module PGN
182
181
  type, value = next_token_pair
183
182
  return nil unless type
184
183
 
185
- Token.new(type: type, value: value, offset: @last_offset, line: @line)
184
+ Token.new(type: type, value: value, offset: @last_offset,
185
+ line: line_at(@last_offset))
186
186
  end
187
187
 
188
188
  # Fast path for the parser: returns [type, value] for the next
@@ -203,7 +203,6 @@ module PGN
203
203
  end
204
204
 
205
205
  value = scan_one
206
- advance_line(value)
207
206
  next if @scan_discarded
208
207
 
209
208
  note_token(@scan_type, off)
@@ -231,7 +230,7 @@ module PGN
231
230
  return m
232
231
  end
233
232
  raise UnconsumedInputError,
234
- "Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{@line}"
233
+ "Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{line_at(@ss.pos)}"
235
234
  end
236
235
 
237
236
  # Track per-game content-start offsets for verbatim pgn slicing.
@@ -246,8 +245,12 @@ module PGN
246
245
  end
247
246
  end
248
247
 
249
- def advance_line(str)
250
- @line += str.count("\n")
248
+ # Line number at a byte offset, computed lazily only for error messages
249
+ # and the spec `tokens` helper. Keeping a running `@line` on the parse hot
250
+ # path (one `str.count("\n")` per token) was ~6% of parse CPU for a value
251
+ # the parser never reads.
252
+ def line_at(off)
253
+ 1 + @input.byteslice(0, off).count("\n")
251
254
  end
252
255
  end
253
256
 
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
@@ -30,7 +30,7 @@ module PGN
30
30
  # 0x88 single-step offsets for knight and king.
31
31
  #
32
32
  STEP = {
33
- 'k' => [-1, 1, -16, 16, -15, 15, -17, 17],
33
+ 'k' => SLIDE['q'],
34
34
  'n' => [33, 31, -31, -33, 18, 14, -14, -18]
35
35
  }.freeze
36
36
 
@@ -42,15 +42,6 @@ module PGN
42
42
  'p' => { capture: [15, 17], normal: [16], double: [32] }
43
43
  }.freeze
44
44
 
45
- # The squares to update for each castling move, keyed by 0x88 index.
46
- #
47
- CASTLING = {
48
- 'Q' => { 0 => nil, 2 => 'K', 3 => 'R', 4 => nil },
49
- 'K' => { 4 => nil, 5 => 'R', 6 => 'K', 7 => nil },
50
- 'q' => { 112 => nil, 114 => 'k', 115 => 'r', 116 => nil },
51
- 'k' => { 116 => nil, 117 => 'r', 118 => 'k', 119 => nil }
52
- }.freeze
53
-
54
45
  # Corner-square 0x88 indices, used for castling-restriction bookkeeping
55
46
  # (a rook leaving or being captured on a corner drops the matching right).
56
47
  #
@@ -59,6 +50,29 @@ module PGN
59
50
  A8 = 112
60
51
  H8 = 119
61
52
 
53
+ # King/rook landing squares for castling, named for readability in
54
+ # {CASTLING} below.
55
+ #
56
+ C1 = 2
57
+ D1 = 3
58
+ E1 = 4
59
+ F1 = 5
60
+ G1 = 6
61
+ C8 = 114
62
+ D8 = 115
63
+ E8 = 116
64
+ F8 = 117
65
+ G8 = 118
66
+
67
+ # The squares to update for each castling move, keyed by 0x88 index.
68
+ #
69
+ CASTLING = {
70
+ 'Q' => { A1 => nil, C1 => 'K', D1 => 'R', E1 => nil },
71
+ 'K' => { E1 => nil, F1 => 'R', G1 => 'K', H1 => nil },
72
+ 'q' => { A8 => nil, C8 => 'k', D8 => 'r', E8 => nil },
73
+ 'k' => { E8 => nil, F8 => 'r', G8 => 'k', H8 => nil }
74
+ }.freeze
75
+
62
76
  # rook-origin (0x88 index) -> castling restriction it drops.
63
77
  #
64
78
  ROOK_RESTRICTIONS = { A1 => 'Q', H1 => 'K', A8 => 'q', H8 => 'k' }.freeze
@@ -69,14 +83,14 @@ module PGN
69
83
  WHITE_CASTLE = %w[K Q].freeze
70
84
  BLACK_CASTLE = %w[k q].freeze
71
85
 
72
- attr_accessor :board, :move
86
+ attr_reader :board, :move
73
87
 
74
88
  # @param board [PGN::Board] the current board
75
89
  # @param move [PGN::Move] the current move
76
90
  #
77
91
  def initialize(board, move)
78
- self.board = board
79
- self.move = move
92
+ @board = board
93
+ @move = move
80
94
  @origin_idx = compute_origin
81
95
  end
82
96
 
@@ -87,7 +101,7 @@ module PGN
87
101
  def origin
88
102
  return nil if @origin_idx.nil?
89
103
 
90
- board.position_for([@origin_idx & 0x0F, @origin_idx >> 4])
104
+ board.square_name(@origin_idx)
91
105
  end
92
106
 
93
107
  # @return [PGN::Board] the board after the move is made
@@ -177,7 +191,7 @@ module PGN
177
191
 
178
192
  possibilities = case move.piece
179
193
  when 'B', 'R', 'Q', 'b', 'r', 'q' then direction_origins
180
- when 'K', 'N', 'k', 'n' then move_origins
194
+ when 'K', 'N', 'k', 'n' then leaper_origins
181
195
  when 'P', 'p' then pawn_origins
182
196
  else # don't care move, used in variations
183
197
  return nil
@@ -215,7 +229,7 @@ module PGN
215
229
  possibilities = []
216
230
  offsets.each do |off|
217
231
  target = dest + off
218
- next unless (target & 0x88).zero? # rubocop:disable Style/BitwisePredicate
232
+ next unless board.on_board?(target)
219
233
 
220
234
  possibilities << target if board.at_index(target) == move.piece
221
235
  end
@@ -223,6 +237,18 @@ module PGN
223
237
  possibilities
224
238
  end
225
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
+
226
252
  # Computes the possible pawn origins based on the destination square
227
253
  # and whether or not the move is a capture.
228
254
  #
@@ -250,7 +276,7 @@ module PGN
250
276
  return possibilities unless move.disambiguation
251
277
 
252
278
  possibilities.select do |idx|
253
- board.position_for([idx & 0x0F, idx >> 4]).match(move.disambiguation)
279
+ board.square_name(idx).match(move.disambiguation)
254
280
  end
255
281
  end
256
282
 
@@ -290,7 +316,7 @@ module PGN
290
316
  #
291
317
  def first_piece(idx, off)
292
318
  idx += off
293
- while (idx & 0x88).zero? # rubocop:disable Style/BitwisePredicate
319
+ while board.on_board?(idx)
294
320
  square = board.at_index(idx)
295
321
  return idx if square
296
322
 
@@ -315,7 +341,7 @@ module PGN
315
341
  return nil if move.castle
316
342
  return nil unless move.capture && board.at_index(dest_idx).nil?
317
343
 
318
- (origin_rank * 16) + (dest_idx & 0x0F)
344
+ board.index_for(dest_idx & 0x0F, origin_rank)
319
345
  end
320
346
 
321
347
  def king_position
@@ -323,7 +349,7 @@ module PGN
323
349
 
324
350
  0.upto(7) do |rank|
325
351
  0.upto(7) do |file|
326
- idx = (rank * 16) + file
352
+ idx = board.index_for(file, rank)
327
353
  return idx if board.at_index(idx) == king
328
354
  end
329
355
  end