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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/README.md +68 -12
- data/TODO.md +27 -15
- data/bench/baseline_moves.txt +6 -6
- data/bench/baseline_parse.txt +6 -6
- data/lib/pgn/board.rb +92 -25
- data/lib/pgn/lexer.rb +43 -7
- data/lib/pgn/move_calculator.rb +157 -162
- data/lib/pgn/notation.rb +474 -0
- data/lib/pgn/pgn_parser.rb +4 -2
- data/lib/pgn/pgn_parser.y +4 -2
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +1 -0
- data/spec/notation_spec.rb +126 -0
- metadata +6 -6
data/lib/pgn/move_calculator.rb
CHANGED
|
@@ -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
|
-
#
|
|
20
|
-
#
|
|
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
|
-
|
|
24
|
-
'b' => [
|
|
25
|
-
'r' => [
|
|
26
|
-
'q' => [
|
|
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
|
-
#
|
|
31
|
-
# they are allowed to make.
|
|
30
|
+
# 0x88 single-step offsets for knight and king.
|
|
32
31
|
#
|
|
33
|
-
|
|
34
|
-
'k' => [
|
|
35
|
-
|
|
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
|
-
#
|
|
41
|
-
#
|
|
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
|
-
|
|
44
|
-
'P' => {
|
|
45
|
-
|
|
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
|
|
45
|
+
# The squares to update for each castling move, keyed by 0x88 index.
|
|
57
46
|
#
|
|
58
47
|
CASTLING = {
|
|
59
|
-
'Q' => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
109
|
+
restrict << 'K' << 'Q'
|
|
119
110
|
when 'k'
|
|
120
|
-
restrict
|
|
111
|
+
restrict << 'k' << 'q'
|
|
121
112
|
when 'R', 'r'
|
|
122
|
-
restrict << ROOK_RESTRICTIONS[
|
|
113
|
+
restrict << ROOK_RESTRICTIONS[@origin_idx]
|
|
123
114
|
end
|
|
124
115
|
|
|
125
116
|
# when castling occurs
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
restrict << '
|
|
132
|
-
restrict << '
|
|
133
|
-
restrict << '
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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! { |
|
|
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
|
-
|
|
188
|
+
possibilities.first
|
|
198
189
|
end
|
|
199
190
|
|
|
200
|
-
# From the destination square,
|
|
201
|
-
#
|
|
202
|
-
#
|
|
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
|
-
|
|
207
|
-
|
|
196
|
+
offsets = SLIDE[move.piece.downcase]
|
|
197
|
+
dest = dest_idx
|
|
208
198
|
|
|
209
|
-
|
|
210
|
-
|
|
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,
|
|
218
|
-
# square and
|
|
219
|
-
#
|
|
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(
|
|
222
|
-
|
|
223
|
-
possibilities = []
|
|
224
|
-
file, rank = destination_coords
|
|
212
|
+
def move_origins(offsets = STEP[move.piece.downcase])
|
|
213
|
+
dest = dest_idx
|
|
225
214
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 <<
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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(
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
270
|
+
king_idx = king_position
|
|
283
271
|
|
|
284
|
-
|
|
272
|
+
SLIDE.each do |attacking_piece, offsets|
|
|
285
273
|
attacking_piece = attacking_piece.upcase if move.black?
|
|
286
274
|
|
|
287
|
-
|
|
288
|
-
square = first_piece(
|
|
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,
|
|
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 `
|
|
300
|
-
# or the first occupied square. Returns that square's
|
|
301
|
-
#
|
|
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(
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
|
320
|
-
# {#
|
|
321
|
-
#
|
|
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(
|
|
325
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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 |
|
|
341
|
-
0.upto(7) do |
|
|
342
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
|
354
|
-
|
|
348
|
+
def dest_rank
|
|
349
|
+
dest_idx >> 4
|
|
355
350
|
end
|
|
356
351
|
end
|
|
357
352
|
end
|