pgn2 1.5.0 → 2.0.1

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 (81) 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 +43 -0
  6. data/.github/workflows/release.yml +52 -5
  7. data/.gitignore +9 -1
  8. data/.rubocop.yml +46 -6
  9. data/CHANGELOG.md +215 -0
  10. data/Gemfile +3 -0
  11. data/NOTICE.md +21 -0
  12. data/README.md +134 -5
  13. data/Rakefile +53 -10
  14. data/TODO.md +44 -62
  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/plans/2026-08-15-game-tree-api-plan.md +1014 -0
  27. data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -0
  28. data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
  29. data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
  30. data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
  31. data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
  32. data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
  33. data/ext/pgn2_native/Cargo.lock +321 -0
  34. data/ext/pgn2_native/Cargo.toml +19 -0
  35. data/ext/pgn2_native/extconf.rb +8 -0
  36. data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
  37. data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
  38. data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
  39. data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
  40. data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
  41. data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
  42. data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
  43. data/lib/pgn/attack.rb +97 -0
  44. data/lib/pgn/bitboard.rb +13 -0
  45. data/lib/pgn/board.rb +64 -0
  46. data/lib/pgn/epd.rb +81 -0
  47. data/lib/pgn/fen.rb +42 -46
  48. data/lib/pgn/game.rb +104 -16
  49. data/lib/pgn/move.rb +20 -16
  50. data/lib/pgn/move_calculator.rb +13 -1
  51. data/lib/pgn/node.rb +372 -0
  52. data/lib/pgn/notation.rb +26 -89
  53. data/lib/pgn/pgn_parser.rb +30 -31
  54. data/lib/pgn/pgn_parser.y +14 -15
  55. data/lib/pgn/position.rb +251 -19
  56. data/lib/pgn/serializer.rb +13 -18
  57. data/lib/pgn/version.rb +1 -1
  58. data/lib/pgn/zobrist.rb +53 -0
  59. data/lib/pgn.rb +5 -0
  60. data/pgn2.gemspec +17 -10
  61. data/spec/bitboard_spec.rb +54 -0
  62. data/spec/board_spec.rb +53 -0
  63. data/spec/castling_normalization_spec.rb +39 -0
  64. data/spec/comment_round_trip_spec.rb +35 -0
  65. data/spec/epd_spec.rb +44 -0
  66. data/spec/fen_spec.rb +71 -65
  67. data/spec/game_history_spec.rb +46 -0
  68. data/spec/game_spec.rb +112 -15
  69. data/spec/lexer_spec.rb +5 -5
  70. data/spec/movetext_clean_spec.rb +44 -0
  71. data/spec/node_spec.rb +240 -0
  72. data/spec/notation_spec.rb +5 -0
  73. data/spec/outcome_spec.rb +93 -0
  74. data/spec/parser_left_recursion_spec.rb +37 -0
  75. data/spec/parser_spec.rb +8 -1
  76. data/spec/position_attack_spec.rb +56 -0
  77. data/spec/position_legal_spec.rb +123 -0
  78. data/spec/position_spec.rb +128 -27
  79. data/spec/serializer_spec.rb +4 -4
  80. data/spec/zobrist_spec.rb +46 -0
  81. metadata +113 -35
data/lib/pgn/node.rb ADDED
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGN
4
+ # {PGN::Node} is a live, lazily-built view over the {PGN::MoveText} tree.
5
+ #
6
+ # A node represents the position reached by playing +move+ from its
7
+ # +parent+'s position; the root has no move and is the game's starting
8
+ # position. The underlying {PGN::MoveText} structure is the source of
9
+ # truth (the parser builds it and the serializer reads it), so a node
10
+ # tree never disagrees with the serialized output.
11
+ #
12
+ # Mutations edit the underlying +MoveText+ arrays in place and, for
13
+ # sibling reorders, normalize the affected branching point to flat
14
+ # sibling storage. After any structural mutation, {PGN::Game#root}
15
+ # returns a fresh tree — outstanding node references are stale.
16
+ class Node
17
+ attr_reader :move, :parent, :line, :index
18
+
19
+ # @param move [PGN::MoveText, nil] the move played to reach this node
20
+ # @param parent [PGN::Node, nil] the parent node (nil for the root)
21
+ # @param line [Array<PGN::MoveText>, nil] the line this node's move
22
+ # lives in (the mainline for mainline nodes, the variation Array for
23
+ # variation nodes; nil conceptually for the root, which passes the
24
+ # mainline)
25
+ # @param index [Integer] index of this node's move within +line+
26
+ # (-1 for the root)
27
+ # @param starting_position [PGN::Position] the root's position
28
+ # @param game [PGN::Game] back-reference so mutations can return a
29
+ # fresh root
30
+ def initialize(move:, parent:, line:, index:, starting_position: nil, game: nil)
31
+ @move = move
32
+ @parent = parent
33
+ @line = line
34
+ @index = index
35
+ @starting_position = starting_position
36
+ @game = game
37
+ end
38
+
39
+ def root?
40
+ @move.nil?
41
+ end
42
+
43
+ def notation
44
+ @move&.notation
45
+ end
46
+
47
+ def annotation
48
+ @move&.annotation
49
+ end
50
+
51
+ def comment
52
+ @move&.comment
53
+ end
54
+
55
+ # All moves playable from this node's position: the continuation move
56
+ # (the next MoveText in this node's line) plus every variation
57
+ # first-move branching at that same position, recursively through
58
+ # nested brackets. The first child is the mainline continuation; the
59
+ # rest are variations in source order.
60
+ def children
61
+ @children ||= begin
62
+ cont = continuation_movetext
63
+ list = []
64
+ collect_first_moves(cont, @line, @index + 1) do |mt, l, idx|
65
+ list << Node.new(move: mt, parent: self, line: l, index: idx, game: @game)
66
+ end
67
+ list
68
+ end
69
+ end
70
+
71
+ # The non-mainline alternatives at this node's position.
72
+ def variations
73
+ children[1..] || []
74
+ end
75
+
76
+ # The mainline continuation, or nil at a terminal position.
77
+ # (Defined via +define_method+ because +next+ is a Ruby keyword and
78
+ # cannot be used with +def+.)
79
+ define_method(:next) { children.first }
80
+
81
+ # The parent node, or nil for the root.
82
+ def previous
83
+ @parent
84
+ end
85
+
86
+ def [](idx)
87
+ children[idx]
88
+ end
89
+
90
+ # Yields each mainline node from +self.next+ onward (the root is
91
+ # excluded), so +main_line.map(&:notation) == game.moves.map(&:notation)+
92
+ # and +main_line.map(&:position) == game.positions[1..]+. Returns an
93
+ # Enumerator when called without a block.
94
+ def main_line
95
+ return enum_for(:main_line) unless block_given?
96
+
97
+ node = self.next
98
+ while node
99
+ yield node
100
+ node = node.next
101
+ end
102
+ end
103
+
104
+ # The position reached at this node: the starting position for the
105
+ # root, otherwise +parent.position+ with +move.notation+ applied. Pure
106
+ # Ruby (no native engine); raises on an illegal SAN exactly like
107
+ # {PGN::Game#positions}. Cached on the node.
108
+ def position
109
+ return @position if defined?(@position)
110
+
111
+ @position = if root?
112
+ @starting_position
113
+ else
114
+ @parent.position.then { |p| p.move(@move.notation) }
115
+ end
116
+ end
117
+
118
+ # Append a new variation line (a single SAN String or an Array<String>)
119
+ # branching before this node's next mainline move. Returns a fresh
120
+ # +game.root+. Raises +ArgumentError+ at a terminal node (a variation
121
+ # must branch before an existing move; use +add_main_variation+ to
122
+ # extend the line).
123
+ def add_variation(move_or_moves)
124
+ cont = continuation_movetext
125
+ raise ArgumentError, 'cannot add a variation at a terminal node' if cont.nil?
126
+
127
+ normalize_branch_point(cont)
128
+ cont.variations << build_movetexts(move_or_moves)
129
+ @game.root
130
+ end
131
+
132
+ # Make the given moves the new mainline continuation from this node's
133
+ # position. At a terminal node this extends the line; otherwise the old
134
+ # continuation becomes a variation of the new first move. Returns a
135
+ # fresh +game.root+.
136
+ def add_main_variation(move_or_moves)
137
+ new_line = build_movetexts(move_or_moves)
138
+ cont = continuation_movetext
139
+
140
+ if cont.nil?
141
+ @line.push(*new_line)
142
+ else
143
+ normalize_branch_point(cont)
144
+ vars = cont.variations
145
+ pos = @index + 1
146
+ old_tail = @line[(pos + 1)..] || []
147
+ n0 = new_line[0]
148
+ @line[pos] = n0
149
+ @line[(pos + 1)..] = (new_line[1..] || [])
150
+ cont.variations = []
151
+ n0.variations = [[cont, *old_tail], *vars]
152
+ end
153
+ @game.root
154
+ end
155
+
156
+ # Move this node one slot toward the mainline among its siblings.
157
+ # No-op for the mainline (index 0) or the first variation (index 1).
158
+ # Returns a fresh +game.root+.
159
+ def promote
160
+ return @game.root if root?
161
+
162
+ i = sibling_index
163
+ return @game.root if i.nil? || i <= 1
164
+
165
+ cont = parent_continuation
166
+ normalize_branch_point(cont)
167
+ vars = cont.variations
168
+ vars[i - 1], vars[i - 2] = vars[i - 2], vars[i - 1]
169
+ @game.root
170
+ end
171
+
172
+ # Move this node one slot toward the end among its siblings. At index 0
173
+ # (the mainline) this is the inverse swap: variation #1 becomes the new
174
+ # mainline and the old mainline becomes variation #1. No-op at the last
175
+ # index. Returns a fresh +game.root+.
176
+ def demote
177
+ return @game.root if root?
178
+
179
+ i = sibling_index
180
+ sibs = @parent.children
181
+ return @game.root if i.nil? || i == sibs.size - 1
182
+
183
+ cont = parent_continuation
184
+ normalize_branch_point(cont)
185
+ vars = cont.variations
186
+ if i.zero?
187
+ v1 = vars.shift
188
+ make_mainline(cont, v1, vars, old_main_position: :first)
189
+ else
190
+ vars[i - 1], vars[i] = vars[i], vars[i - 1]
191
+ end
192
+ @game.root
193
+ end
194
+
195
+ # Make this node the mainline at its branching point (the old mainline
196
+ # becomes variation #1). No-op if already the mainline. Returns a
197
+ # fresh +game.root+.
198
+ def promote_to_main
199
+ return @game.root if root?
200
+
201
+ i = sibling_index
202
+ return @game.root if i.nil? || i.zero?
203
+
204
+ cont = parent_continuation
205
+ normalize_branch_point(cont)
206
+ vars = cont.variations
207
+ vk = vars.delete_at(i - 1)
208
+ make_mainline(cont, vk, vars, old_main_position: :first)
209
+ @game.root
210
+ end
211
+
212
+ # Move this node to the last position among its siblings. At index 0
213
+ # the last variation becomes the new mainline and the old mainline
214
+ # becomes the last variation. Returns a fresh +game.root+.
215
+ def demote_to_last
216
+ return @game.root if root?
217
+
218
+ i = sibling_index
219
+ return @game.root if i.nil?
220
+
221
+ cont = parent_continuation
222
+ normalize_branch_point(cont)
223
+ vars = cont.variations
224
+ if i.zero?
225
+ return @game.root if vars.empty?
226
+
227
+ last = vars.pop
228
+ make_mainline(cont, last, vars, old_main_position: :last)
229
+ else
230
+ el = vars.delete_at(i - 1)
231
+ vars.push(el)
232
+ end
233
+ @game.root
234
+ end
235
+
236
+ # Remove this node and its subtree. If it is the mainline continuation,
237
+ # the first remaining variation (if any) takes its place; otherwise the
238
+ # line is truncated at this point. Returns a fresh +game.root+.
239
+ def delete
240
+ return @game.root if root?
241
+
242
+ i = sibling_index
243
+ return @game.root if i.nil?
244
+
245
+ cont = parent_continuation
246
+ normalize_branch_point(cont)
247
+ vars = cont.variations
248
+ if i.zero?
249
+ delete_mainline_continuation(cont, vars)
250
+ else
251
+ vars.delete_at(i - 1)
252
+ end
253
+ @game.root
254
+ end
255
+
256
+ private
257
+
258
+ # The MoveText played from this node's position in the current line:
259
+ # the next entry in +line+ (nil at a terminal position). For the root,
260
+ # +index+ is -1, so this is +line[0]+ (the first mainline move).
261
+ def continuation_movetext
262
+ @line && @line[@index + 1]
263
+ end
264
+
265
+ # Yield every first-move branching at the position before +m+ (i.e. at
266
+ # this node's position): +m+ itself (in +l+ at +idx+), plus the first
267
+ # move of each of +m+'s variations, plus — recursively — the first
268
+ # moves of any variations nested on those first moves (they branch at
269
+ # the same point, since a variation branches before the move it
270
+ # attaches to).
271
+ def collect_first_moves(movetext, line, idx, &block)
272
+ return if movetext.nil?
273
+
274
+ block.call(movetext, line, idx)
275
+ (movetext.variations || []).each do |variation|
276
+ next unless variation && variation[0]
277
+
278
+ collect_first_moves(variation[0], variation, 0, &block)
279
+ end
280
+ end
281
+
282
+ # Index of this node among its parent's children, or nil if root.
283
+ def sibling_index
284
+ @parent.children.index(self)
285
+ end
286
+
287
+ # The MoveText that is the mainline continuation at the PARENT's
288
+ # position (the move this node is an alternative to, or this node
289
+ # itself if it is the continuation).
290
+ def parent_continuation
291
+ @parent.line[@parent.index + 1]
292
+ end
293
+
294
+ # Build an Array<PGN::MoveText> from a single SAN String or an Array of
295
+ # SANs, applying the same castling 0->O normalization as Game#moves=.
296
+ def build_movetexts(move_or_moves)
297
+ sans = move_or_moves.is_a?(String) ? [move_or_moves] : move_or_moves
298
+ sans.map { |s| PGN::MoveText.new(normalize_castling(s)) }
299
+ end
300
+
301
+ def normalize_castling(san)
302
+ san.include?('0') ? san.gsub('0', 'O') : san
303
+ end
304
+
305
+ # Hoist every variation first-move branching at the position before
306
+ # +cont+ (recursively through nested brackets) into a single flat
307
+ # +cont.variations+ array, clearing the first-moves' at-point variations
308
+ # (they are now flat siblings). Internal structure of each variation
309
+ # line (tail moves and their deeper variations) is untouched. After
310
+ # this, +cont.variations+ is a flat Array<Array<MoveText>> in the same
311
+ # order +children+ yields.
312
+ def normalize_branch_point(cont)
313
+ return if cont.nil?
314
+
315
+ lines = []
316
+ collect_variation_lines(cont) { |v| lines << v }
317
+ lines.each { |v| v[0].variations = [] }
318
+ cont.variations = lines
319
+ end
320
+
321
+ # Yield each variation line branching at the position before +m+ (in
322
+ # DFS order): +m+'s direct variations, plus — recursively — the
323
+ # variations nested on each variation's first move (they branch at the
324
+ # same point).
325
+ def collect_variation_lines(movetext, &block)
326
+ (movetext.variations || []).each do |variation|
327
+ next unless variation && variation[0]
328
+
329
+ block.call(variation)
330
+ collect_variation_lines(variation[0], &block)
331
+ end
332
+ end
333
+
334
+ # Make +vk+ (= [vk0, *tail]) the new mainline continuation at the
335
+ # parent's position, replacing +cont+. The old mainline (+cont+ and
336
+ # its tail) becomes a variation appended to +others+ either before
337
+ # (+old_main_position == :first+) or after (+:last+). +cont.variations+
338
+ # is cleared (its at-point variations are now +vk0+'s siblings).
339
+ def make_mainline(cont, variation, others, old_main_position:)
340
+ line = @parent.line
341
+ pos = @parent.index + 1
342
+ old_tail = line[(pos + 1)..] || []
343
+ variation0 = variation[0]
344
+ line[pos] = variation0
345
+ line[(pos + 1)..] = (variation[1..] || [])
346
+ cont.variations = []
347
+ old_var = [cont, *old_tail]
348
+ variation0.variations =
349
+ old_main_position == :first ? [old_var, *others] : [*others, old_var]
350
+ end
351
+
352
+ # Replace the mainline continuation +cont+ with its first variation
353
+ # (+vars+ is +cont.variations+, flat). If there are no variations, the
354
+ # line is truncated at the continuation; otherwise the first variation
355
+ # takes the continuation's place and the remaining variations become
356
+ # its variations.
357
+ def delete_mainline_continuation(cont, vars)
358
+ line = @parent.line
359
+ pos = @parent.index + 1
360
+ if vars.empty?
361
+ line[pos..] = []
362
+ else
363
+ first_variation = vars.shift
364
+ first_variation_move = first_variation[0]
365
+ line[pos] = first_variation_move
366
+ line[(pos + 1)..] = (first_variation[1..] || [])
367
+ cont.variations = []
368
+ first_variation_move.variations = vars
369
+ end
370
+ end
371
+ end
372
+ end
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
 
@@ -145,8 +145,8 @@ module PGN
145
145
 
146
146
  def suffix(from_idx, to_idx, piece, promotion, castling)
147
147
  nb = apply_move(from_idx, to_idx, piece, promotion, castling)
148
- opp_king = king_idx(nb, @enemy)
149
- return '' unless opp_king && attacked?(nb, opp_king, @mover)
148
+ opp_king = PGN::Attack.king_idx(nb, @enemy)
149
+ return '' unless opp_king && PGN::Attack.attacked?(nb, opp_king, @mover)
150
150
 
151
151
  result_pos = PGN::Position.new(
152
152
  nb,
@@ -219,8 +219,8 @@ module PGN
219
219
  nb.update_index(ep, nil) if ep
220
220
  nb.update_index(from_idx, nil)
221
221
  nb.update_index(to_idx, piece)
222
- king = king_idx(nb, @mover)
223
- king && !attacked?(nb, king, @enemy)
222
+ king = PGN::Attack.king_idx(nb, @mover)
223
+ king && !PGN::Attack.attacked?(nb, king, @enemy)
224
224
  end
225
225
 
226
226
  # Does `piece` at `from_idx` pseudo-legally reach `to_idx` on the board?
@@ -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').
@@ -278,71 +278,8 @@ module PGN
278
278
 
279
279
  # -- attack / legality helpers ----------------------------------------
280
280
 
281
- def king_idx(board, color)
282
- king = color == 'w' ? 'K' : 'k'
283
- (0...128).each do |idx|
284
- next if (idx & 0x88) != 0
285
-
286
- return idx if board.at_index(idx) == king
287
- end
288
- nil
289
- end
290
-
291
- # Is `target` attacked by `color`-colored pieces on `board`?
292
- def attacked?(board, target, color)
293
- pawn_attacked?(board, target, color) ||
294
- knight_attacked?(board, target, color) ||
295
- king_attacked?(board, target, color) ||
296
- slider_attacked?(board, target, color)
297
- end
298
-
299
- def pawn_attacked?(board, target, color)
300
- offs = color == 'w' ? [-15, -17] : [15, 17]
301
- pawn = color == 'w' ? 'P' : 'p'
302
- offs.any? do |off|
303
- i = target + off
304
- (i & 0x88).zero? && board.at_index(i) == pawn
305
- end
306
- end
307
-
308
- def knight_attacked?(board, target, color)
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
314
- end
315
-
316
- def king_attacked?(board, target, color)
317
- 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
322
- end
323
-
324
- def slider_attacked?(board, target, color)
325
- bishop = color == 'w' ? 'B' : 'b'
326
- rook = color == 'w' ? 'R' : 'r'
327
- queen = color == 'w' ? 'Q' : 'q'
328
- ray_hit?(board, target, BISHOP_DIRS) { |p| p == bishop || p == queen } ||
329
- ray_hit?(board, target, ROOK_DIRS) { |p| p == rook || p == queen }
330
- end
331
-
332
- def ray_hit?(board, target, dirs)
333
- dirs.each do |off|
334
- i = target + off
335
- while (i & 0x88).zero?
336
- piece = board.at_index(i)
337
- if piece
338
- return true if yield(piece)
339
- break
340
- end
341
- i += off
342
- end
343
- end
344
- false
345
- end
281
+ # (Attack detection is delegated to {PGN::Attack}, the shared source of
282
+ # truth used by {PGN::Position#in_check?} and {PGN::Position#attackers}.)
346
283
 
347
284
  # -- legal-move generation (for checkmate detection) ------------------
348
285
 
@@ -352,7 +289,7 @@ module PGN
352
289
  ep_idx = ep_target_idx(position, board)
353
290
 
354
291
  (0...128).any? do |idx|
355
- next if (idx & 0x88) != 0
292
+ next if idx.anybits?(0x88)
356
293
 
357
294
  piece = board.at_index(idx)
358
295
  piece && own?(piece, color) && move_from?(board, idx, piece, color, ep_idx)
@@ -370,8 +307,8 @@ module PGN
370
307
  def move_from?(board, idx, piece, color, ep_idx)
371
308
  up = piece.upcase
372
309
  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)
310
+ when 'N' then leaper_moves?(board, idx, piece, color, Board::KNIGHT_ATTACKS[idx])
311
+ when 'K' then leaper_moves?(board, idx, piece, color, Board::KING_ATTACKS[idx])
375
312
  when 'B', 'R', 'Q'
376
313
  slider_moves?(board, idx, piece, color, slider_dirs(up))
377
314
  when 'P'
@@ -381,22 +318,22 @@ module PGN
381
318
  end
382
319
  end
383
320
 
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)
321
+ def leaper_moves?(board, idx, piece, color, targets)
322
+ targets.any? do |t|
323
+ landable?(board, t, color) && safe?(board, idx, t, piece, nil)
388
324
  end
389
325
  end
390
326
 
391
327
  def slider_moves?(board, idx, piece, color, dirs)
392
328
  dirs.any? do |off|
393
329
  t = idx + off
394
- while (t & 0x88).zero?
330
+ while t.nobits?(0x88)
395
331
  tp = board.at_index(t)
396
332
  if tp.nil?
397
333
  return true if safe?(board, idx, t, piece, nil)
398
334
  else
399
335
  return true if !own?(tp, color) && safe?(board, idx, t, piece, nil)
336
+
400
337
  break
401
338
  end
402
339
  t += off
@@ -416,24 +353,24 @@ module PGN
416
353
  promo_rank = color == 'w' ? 7 : 0
417
354
 
418
355
  t = idx + dir
419
- return false unless (t & 0x88).zero? && board.at_index(t).nil?
356
+ return false unless t.nobits?(0x88) && board.at_index(t).nil?
420
357
 
421
- promo = (t >> 4 == promo_rank) ? 'Q' : nil
358
+ promo = t >> 4 == promo_rank ? 'Q' : nil
422
359
  return true if safe?(board, idx, t, piece, promo)
423
360
 
424
361
  t2 = idx + (dir * 2)
425
362
  (idx >> 4) == start_rank && board.at_index(t2).nil? && safe?(board, idx, t2, piece, nil)
426
363
  end
427
364
 
428
- def pawn_captures?(board, idx, piece, color, ep_idx) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
365
+ def pawn_captures?(board, idx, piece, color, ep_idx)
429
366
  promo_rank = color == 'w' ? 7 : 0
430
367
  caps = color == 'w' ? [15, 17] : [-15, -17]
431
368
  caps.any? do |off|
432
369
  t = idx + off
433
- next false unless (t & 0x88).zero?
370
+ next false unless t.nobits?(0x88)
434
371
 
435
372
  tp = board.at_index(t)
436
- promo = (t >> 4 == promo_rank) ? 'Q' : nil
373
+ promo = t >> 4 == promo_rank ? 'Q' : nil
437
374
  if tp && !own?(tp, color)
438
375
  safe?(board, idx, t, piece, promo)
439
376
  elsif tp.nil? && t == ep_idx
@@ -459,8 +396,8 @@ module PGN
459
396
  nb.update_index(from_idx, nil)
460
397
  nb.update_index(to_idx, place_piece(piece, promotion))
461
398
  color = own?(piece, 'w') ? 'w' : 'b'
462
- king = king_idx(nb, color)
463
- king && !attacked?(nb, king, color == 'w' ? 'b' : 'w')
399
+ king = PGN::Attack.king_idx(nb, color)
400
+ king && !PGN::Attack.attacked?(nb, king, color == 'w' ? 'b' : 'w')
464
401
  end
465
402
 
466
403
  # The piece letter to place on the destination after a pseudo-move.