pgn2 1.2.1 → 1.4.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.
@@ -6,88 +6,70 @@ module PGN
6
6
  # the board need to be updated, new castling restrictions, the en passant
7
7
  # square and whether to update fullmove and halfmove counters.
8
8
  #
9
+ # Squares are addressed as 0x88 integer indices (see {PGN::Board}); this
10
+ # keeps the replay hot path free of `[file, rank]` coordinate arrays and
11
+ # square-name string allocations. The public {#origin} reader still returns
12
+ # an algebraic square string for API compatibility.
13
+ #
9
14
  # @!attribute board
10
15
  # @return [PGN::Board] the current board
11
16
  #
12
17
  # @!attribute move
13
18
  # @return [PGN::Move] the current move
14
19
  #
15
- # @!attribute origin
16
- # @return [String, nil] the origin square in SAN
17
- #
18
20
  class MoveCalculator
19
- # Specifies the movement of pieces who are allowed to move in a
20
- # given direction until they reach an obstacle or the end of the
21
- # board.
21
+ # 0x88 ray-step offsets for sliding pieces. A step is a single integer
22
+ # add; off-board is `(idx & 0x88) != 0`, which also catches file wraparound.
22
23
  #
23
- DIRECTIONS = {
24
- 'b' => [[1, 1], [-1, 1], [-1, -1], [1, -1]],
25
- 'r' => [[-1, 0], [1, 0], [0, -1], [0, 1]],
26
- 'q' => [[1, 1], [-1, 1], [-1, -1], [1, -1],
27
- [-1, 0], [1, 0], [0, -1], [0, 1]]
24
+ SLIDE = {
25
+ 'b' => [-15, 15, -17, 17],
26
+ 'r' => [-1, 1, -16, 16],
27
+ 'q' => [-1, 1, -16, 16, -15, 15, -17, 17]
28
28
  }.freeze
29
29
 
30
- # Specifies the movement of pieces that have a limited set of moves
31
- # they are allowed to make.
30
+ # 0x88 single-step offsets for knight and king.
32
31
  #
33
- MOVES = {
34
- 'k' => [[-1, -1], [0, -1], [1, -1], [1, 0],
35
- [1, 1], [0, 1], [-1, 1], [-1, 0]],
36
- 'n' => [[-1, -2], [-1, 2], [1, -2], [1, 2],
37
- [-2, -1], [2, -1], [-2, 1], [2, 1]]
32
+ STEP = {
33
+ 'k' => [-1, 1, -16, 16, -15, 15, -17, 17],
34
+ 'n' => [33, 31, -31, -33, 18, 14, -14, -18]
38
35
  }.freeze
39
36
 
40
- # Specifies possible pawn movements. It may seem backwards since it is
41
- # used to compute the origin square and not the destination.
37
+ # Possible pawn origins, expressed as offsets from the destination square
38
+ # (pawn moves are computed backwards from where the pawn landed).
42
39
  #
43
- PAWN_MOVES = {
44
- 'P' => {
45
- capture: [[-1, -1], [1, -1]],
46
- normal: [[0, -1]],
47
- double: [[0, -2]]
48
- },
49
- 'p' => {
50
- capture: [[-1, 1], [1, 1]],
51
- normal: [[0, 1]],
52
- double: [[0, 2]]
53
- }
40
+ PAWN_OFFSETS = {
41
+ 'P' => { capture: [-17, -15], normal: [-16], double: [-32] },
42
+ 'p' => { capture: [15, 17], normal: [16], double: [32] }
54
43
  }.freeze
55
44
 
56
- # The squares to update for each possible castling move.
45
+ # The squares to update for each castling move, keyed by 0x88 index.
57
46
  #
58
47
  CASTLING = {
59
- 'Q' => {
60
- 'a1' => nil,
61
- 'c1' => 'K',
62
- 'd1' => 'R',
63
- 'e1' => nil
64
- },
65
- 'K' => {
66
- 'e1' => nil,
67
- 'f1' => 'R',
68
- 'g1' => 'K',
69
- 'h1' => nil
70
- },
71
- 'q' => {
72
- 'a8' => nil,
73
- 'c8' => 'k',
74
- 'd8' => 'r',
75
- 'e8' => nil
76
- },
77
- 'k' => {
78
- 'e8' => nil,
79
- 'f8' => 'r',
80
- 'g8' => 'k',
81
- 'h8' => nil
82
- }
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 }
83
52
  }.freeze
84
53
 
85
- # Frozen rook-origin -> castling-restriction lookup, shared by both
86
- # white ('R') and black ('r') since their rook origins (a1/h1, a8/h8)
87
- # are distinct keys. Replaces a per-call hash literal.
88
- ROOK_RESTRICTIONS = { 'a1' => 'Q', 'h1' => 'K', 'a8' => 'q', 'h8' => 'k' }.freeze
54
+ # Corner-square 0x88 indices, used for castling-restriction bookkeeping
55
+ # (a rook leaving or being captured on a corner drops the matching right).
56
+ #
57
+ A1 = 0
58
+ H1 = 7
59
+ A8 = 112
60
+ H8 = 119
61
+
62
+ # rook-origin (0x88 index) -> castling restriction it drops.
63
+ #
64
+ ROOK_RESTRICTIONS = { A1 => 'Q', H1 => 'K', A8 => 'q', H8 => 'k' }.freeze
65
+
66
+ # Castling-move characters by side, for the "castling occurs" restriction.
67
+ # Frozen so {Array#include?} does not allocate per call.
68
+ #
69
+ WHITE_CASTLE = %w[K Q].freeze
70
+ BLACK_CASTLE = %w[k q].freeze
89
71
 
90
- attr_accessor :board, :move, :origin
72
+ attr_accessor :board, :move
91
73
 
92
74
  # @param board [PGN::Board] the current board
93
75
  # @param move [PGN::Move] the current move
@@ -95,14 +77,24 @@ module PGN
95
77
  def initialize(board, move)
96
78
  self.board = board
97
79
  self.move = move
98
- self.origin = compute_origin
80
+ @origin_idx = compute_origin
81
+ end
82
+
83
+ # @return [String, nil] the origin square in algebraic notation, for API
84
+ # compatibility. Internally the calculator works with the 0x88 index
85
+ # (see {#origin_idx}); this reader materialises the string on demand.
86
+ #
87
+ def origin
88
+ return nil if @origin_idx.nil?
89
+
90
+ board.position_for([@origin_idx & 0x0F, @origin_idx >> 4])
99
91
  end
100
92
 
101
93
  # @return [PGN::Board] the board after the move is made
102
94
  #
103
95
  def result_board
104
96
  new_board = board.dup
105
- new_board.change!(changes)
97
+ new_board.apply!(changes)
106
98
 
107
99
  new_board
108
100
  end
@@ -112,25 +104,28 @@ module PGN
112
104
  def castling_restrictions
113
105
  restrict = []
114
106
 
115
- # when a king or rook is moved
116
107
  case move.piece
117
108
  when 'K'
118
- restrict += %w[K Q]
109
+ restrict << 'K' << 'Q'
119
110
  when 'k'
120
- restrict += %w[k q]
111
+ restrict << 'k' << 'q'
121
112
  when 'R', 'r'
122
- restrict << ROOK_RESTRICTIONS[origin]
113
+ restrict << ROOK_RESTRICTIONS[@origin_idx]
123
114
  end
124
115
 
125
116
  # when castling occurs
126
- restrict += %w[K Q] if %w[K Q].include?(move.castle)
127
- restrict += %w[k q] if %w[k q].include?(move.castle)
117
+ if WHITE_CASTLE.include?(move.castle)
118
+ restrict << 'K' << 'Q'
119
+ elsif BLACK_CASTLE.include?(move.castle)
120
+ restrict << 'k' << 'q'
121
+ end
128
122
 
129
123
  # when a rook is taken
130
- restrict << 'Q' if move.destination == 'a1'
131
- restrict << 'q' if move.destination == 'a8'
132
- restrict << 'K' if move.destination == 'h1'
133
- restrict << 'k' if move.destination == 'h8'
124
+ dest = dest_idx
125
+ restrict << 'Q' if dest == A1
126
+ restrict << 'q' if dest == A8
127
+ restrict << 'K' if dest == H1
128
+ restrict << 'k' if dest == H8
134
129
 
135
130
  restrict.empty? ? restrict : restrict.compact.uniq
136
131
  end
@@ -151,35 +146,31 @@ module PGN
151
146
  #
152
147
  def en_passant_square
153
148
  return nil if move.castle
149
+ return nil unless move.pawn? && ((origin_rank - dest_rank).abs == 2)
154
150
 
155
- return unless move.pawn? && (origin[1].to_i - move.destination[1].to_i).abs == 2
156
-
157
- if move.white?
158
- "#{origin[0]}3"
159
- else
160
- "#{origin[0]}6"
161
- end
151
+ Board::INDEX_TO_FILE[origin_file] + (move.white? ? '3' : '6')
162
152
  end
163
153
 
164
154
  private
165
155
 
156
+ # The integer-indexed changes to apply to the board. Keys are 0x88
157
+ # indices, so no square-name strings are allocated on the hot path.
158
+ #
166
159
  def changes
167
160
  changes = {}
168
161
  changes.merge!(CASTLING[move.castle]) if move.castle
169
- changes.merge!(
170
- origin => nil,
171
- move.destination => move.piece,
172
- en_passant_capture => nil
173
- )
174
- changes[move.destination] = move.promotion if move.promotion
162
+ changes[@origin_idx] = nil
163
+ changes[dest_idx] = move.piece
164
+ changes[en_passant_capture] = nil
165
+ changes[dest_idx] = move.promotion if move.promotion
175
166
 
176
- changes.reject! { |key, _| key.nil? or key.empty? }
167
+ changes.reject! { |idx, _| idx.nil? }
177
168
 
178
169
  changes
179
170
  end
180
171
 
181
172
  # Using the current position and move, figure out where the piece
182
- # came from.
173
+ # came from (as a 0x88 index).
183
174
  #
184
175
  def compute_origin
185
176
  return nil if move.castle
@@ -194,58 +185,55 @@ module PGN
194
185
 
195
186
  possibilities = disambiguate(possibilities) if possibilities.length > 1
196
187
 
197
- board.position_for(possibilities.first)
188
+ possibilities.first
198
189
  end
199
190
 
200
- # From the destination square, move in each direction stopping if we
201
- # reach the end of the board. If we encounter a piece, add it to the
202
- # list of origin possibilities if it is the moving piece, or else
203
- # check the next direction.
191
+ # From the destination square, walk each slider direction until the first
192
+ # occupied square. If that piece is the moving piece, the square it sits
193
+ # on is a possible origin.
204
194
  #
205
195
  def direction_origins
206
- directions = DIRECTIONS[move.piece.downcase]
207
- possibilities = []
196
+ offsets = SLIDE[move.piece.downcase]
197
+ dest = dest_idx
208
198
 
209
- directions.each do |dir|
210
- square = first_piece(destination_coords, dir)
199
+ possibilities = []
200
+ offsets.each do |off|
201
+ square = first_piece(dest, off)
211
202
  possibilities << square if piece_at(square) == move.piece
212
203
  end
213
204
 
214
205
  possibilities
215
206
  end
216
207
 
217
- # From the destination square, make each move. If it is a valid
218
- # square and matches the moving piece, add it to the list of origin
219
- # possibilities.
208
+ # From the destination square, apply each single-step offset. If the
209
+ # target square is on the board and holds the moving piece, it is a
210
+ # possible origin.
220
211
  #
221
- def move_origins(moves = nil)
222
- moves ||= MOVES[move.piece.downcase]
223
- possibilities = []
224
- file, rank = destination_coords
212
+ def move_origins(offsets = STEP[move.piece.downcase])
213
+ dest = dest_idx
225
214
 
226
- moves.each do |i, j|
227
- f = file + i
228
- r = rank + j
215
+ possibilities = []
216
+ offsets.each do |off|
217
+ target = dest + off
218
+ next unless (target & 0x88).zero? # rubocop:disable Style/BitwisePredicate
229
219
 
230
- possibilities << [f, r] if valid_square?(f, r) && board.at(f, r) == move.piece
220
+ possibilities << target if board.at_index(target) == move.piece
231
221
  end
232
222
 
233
223
  possibilities
234
224
  end
235
225
 
236
- # Computes the possbile pawn origins based on the destination square
226
+ # Computes the possible pawn origins based on the destination square
237
227
  # and whether or not the move is a capture.
238
228
  #
239
229
  def pawn_origins
240
- _, rank = destination_coords
241
- double_rank = (rank == 3 && move.white?) || (rank == 4 && move.black?)
242
-
243
- pawn_moves = PAWN_MOVES[move.piece]
230
+ double = (dest_rank == 3 && move.white?) || (dest_rank == 4 && move.black?)
244
231
 
245
- moves = move.capture ? pawn_moves[:capture] : pawn_moves[:normal]
246
- moves += pawn_moves[:double] if double_rank
232
+ pawn_moves = PAWN_OFFSETS[move.piece]
233
+ offsets = move.capture ? pawn_moves[:capture] : pawn_moves[:normal]
234
+ offsets += pawn_moves[:double] if double
247
235
 
248
- move_origins(moves)
236
+ move_origins(offsets)
249
237
  end
250
238
 
251
239
  def disambiguate(possibilities)
@@ -259,36 +247,36 @@ module PGN
259
247
  # Try to disambiguate based on the standard algebraic notation.
260
248
  #
261
249
  def disambiguate_san(possibilities)
262
- if move.disambiguation
263
- possibilities.select { |p| board.position_for(p).match(move.disambiguation) }
264
- else
265
- possibilities
250
+ return possibilities unless move.disambiguation
251
+
252
+ possibilities.select do |idx|
253
+ board.position_for([idx & 0x0F, idx >> 4]).match(move.disambiguation)
266
254
  end
267
255
  end
268
256
 
269
- # A pawn can't move two spaces if there is a pawn in front of it.
257
+ # A pawn can't move two spaces if there is a pawn in front of it. A
258
+ # double-push origin sits on rank 2 (white) or 7 (black); reject those
259
+ # candidates when more than one pawn could have reached the destination.
270
260
  #
271
261
  def disambiguate_pawns(possibilities)
272
- if move.piece.match(/p/i) && !move.capture
273
- possibilities.reject { |p| board.position_for(p).match(/2|7/) }
274
- else
275
- possibilities
276
- end
262
+ return possibilities unless move.piece.match?(/p/i) && !move.capture
263
+
264
+ possibilities.reject { |idx| (idx >> 4) == 1 || (idx >> 4) == 6 }
277
265
  end
278
266
 
279
267
  # A piece can't move if it would result in a discovered check.
280
268
  #
281
269
  def disambiguate_discovered_check(possibilities)
282
- king_pos = king_position
270
+ king_idx = king_position
283
271
 
284
- DIRECTIONS.each do |attacking_piece, directions|
272
+ SLIDE.each do |attacking_piece, offsets|
285
273
  attacking_piece = attacking_piece.upcase if move.black?
286
274
 
287
- directions.each do |dir|
288
- square = first_piece(king_pos, dir)
275
+ offsets.each do |off|
276
+ square = first_piece(king_idx, off)
289
277
  next unless piece_at(square) == move.piece && possibilities.include?(square)
290
278
 
291
- next_square = first_piece(square, dir)
279
+ next_square = first_piece(square, off)
292
280
  possibilities.reject! { |p| p == square } if piece_at(next_square) == attacking_piece
293
281
  end
294
282
  end
@@ -296,62 +284,69 @@ module PGN
296
284
  possibilities
297
285
  end
298
286
 
299
- # Walks from `from` in `direction` until it reaches the edge of the board
300
- # or the first occupied square. Returns that square's `[file, rank]`
301
- # coordinates, or `nil` if no piece was encountered before the edge. The
302
- # caller reads the piece off the board itself, so this avoids allocating
303
- # the `[piece, square]` wrapper tuple per direction scan.
287
+ # Walks from `idx` in the 0x88 direction `off` until it reaches the edge
288
+ # of the board or the first occupied square. Returns that square's 0x88
289
+ # index, or nil if no piece was encountered before the edge.
304
290
  #
305
- def first_piece(from, direction)
306
- file, rank = from
307
- i, j = direction
291
+ def first_piece(idx, off)
292
+ idx += off
293
+ while (idx & 0x88).zero? # rubocop:disable Style/BitwisePredicate
294
+ square = board.at_index(idx)
295
+ return idx if square
308
296
 
309
- loop do
310
- file += i
311
- rank += j
312
- return nil if file.negative? || file > 7 || rank.negative? || rank > 7
313
-
314
- square = [file, rank]
315
- return square if board.at(file, rank)
297
+ idx += off
316
298
  end
299
+ nil
317
300
  end
318
301
 
319
- # Reads the piece on a square, tolerating a nil square (returned by
320
- # {#first_piece} when the scan ran off the edge). Kept as a helper so the
321
- # callers read the piece once instead of unpacking a `[piece, square]`
322
- # tuple per direction scan.
302
+ # Reads the piece at a 0x88 index, returning nil for an off-board (nil)
303
+ # index. Keeps {#disambiguate_discovered_check} within the configured
304
+ # complexity limits.
323
305
  #
324
- def piece_at(square)
325
- square && board.at(square[0], square[1])
306
+ def piece_at(idx)
307
+ idx && board.at_index(idx)
326
308
  end
327
309
 
328
- # If the move is a capture and there is no piece on the
329
- # destination square, it must be an en passant capture.
310
+ # If the move is a capture and there is no piece on the destination
311
+ # square, it must be an en passant capture. The captured pawn sits on the
312
+ # destination file and the moving pawn's origin rank.
330
313
  #
331
314
  def en_passant_capture
332
315
  return nil if move.castle
316
+ return nil unless move.capture && board.at_index(dest_idx).nil?
333
317
 
334
- move.destination[0] + origin[1] if !board.at(move.destination) && move.capture
318
+ (origin_rank * 16) + (dest_idx & 0x0F)
335
319
  end
336
320
 
337
321
  def king_position
338
322
  king = move.white? ? 'K' : 'k'
339
323
 
340
- 0.upto(7) do |file|
341
- 0.upto(7) do |rank|
342
- return [file, rank] if board.at(file, rank) == king
324
+ 0.upto(7) do |rank|
325
+ 0.upto(7) do |file|
326
+ idx = (rank * 16) + file
327
+ return idx if board.at_index(idx) == king
343
328
  end
344
329
  end
345
330
 
346
331
  nil
347
332
  end
348
333
 
349
- def valid_square?(file, rank)
350
- file >= 0 && file < 8 && rank >= 0 && rank < 8
334
+ # -- 0x88 index helpers --------------------------------------------------
335
+
336
+ def dest_idx
337
+ @dest_idx ||= move.destination && board.index_of(move.destination)
338
+ end
339
+
340
+ def origin_file
341
+ @origin_idx & 0x0F
342
+ end
343
+
344
+ def origin_rank
345
+ @origin_idx >> 4
351
346
  end
352
347
 
353
- def destination_coords
354
- @destination_coords ||= board.coordinates_for(move.destination)
348
+ def dest_rank
349
+ dest_idx >> 4
355
350
  end
356
351
  end
357
352
  end