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/notation.rb
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGN
|
|
4
|
+
# {PGN::Notation} generates Standard Algebraic Notation (SAN) for a single
|
|
5
|
+
# move described in coordinate form -- an origin square, a destination
|
|
6
|
+
# square, and an optional promotion piece -- given the {PGN::Position}
|
|
7
|
+
# *before* the move is played.
|
|
8
|
+
#
|
|
9
|
+
# Unlike {PGN::Move}, which *parses* an existing SAN string, {PGN::Notation}
|
|
10
|
+
# *builds* SAN from coordinates. This requires the kind of legality analysis
|
|
11
|
+
# the rest of the gem does not perform: attack detection, "does this move
|
|
12
|
+
# leave the mover's king in check", disambiguation among same-type pieces
|
|
13
|
+
# that can legally reach the destination, and check (+) / checkmate (#)
|
|
14
|
+
# suffix detection (the latter needing full legal-move generation for the
|
|
15
|
+
# side to move).
|
|
16
|
+
#
|
|
17
|
+
# The board is addressed with the same 0x88 integer indices the rest of the
|
|
18
|
+
# gem uses (see {PGN::Board}); an index is on-board when
|
|
19
|
+
# `(idx & 0x88).zero?`.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# PGN::Notation.san(PGN::Position.start, 'g1', 'f3') #=> "Nf3"
|
|
23
|
+
# PGN::Notation.san_from_fen(PGN::FEN::INITIAL, 'e2', 'e4') #=> "e4"
|
|
24
|
+
#
|
|
25
|
+
class Notation
|
|
26
|
+
# 0x88 single-step offsets for a knight (symmetric, so they double as
|
|
27
|
+
# attack deltas).
|
|
28
|
+
KNIGHT_OFFS = [33, 31, -31, -33, 18, 14, -14, -18].freeze
|
|
29
|
+
# 0x88 single-step offsets for a king (symmetric).
|
|
30
|
+
KING_OFFS = [-1, 1, -16, 16, -15, 15, -17, 17].freeze
|
|
31
|
+
BISHOP_DIRS = [-15, 15, -17, 17].freeze
|
|
32
|
+
ROOK_DIRS = [-1, 1, -16, 16].freeze
|
|
33
|
+
|
|
34
|
+
# Build SAN for a coordinate move from a {PGN::Position}.
|
|
35
|
+
#
|
|
36
|
+
# @param position [PGN::Position] the position *before* the move
|
|
37
|
+
# @param from [String] origin square in algebraic notation ("e2")
|
|
38
|
+
# @param to [String] destination square in algebraic notation ("e4")
|
|
39
|
+
# @param promotion [String, nil] promotion piece letter ("q"/"Q"/"n"...);
|
|
40
|
+
# case-insensitive; SAN always emits an uppercase letter
|
|
41
|
+
# @return [String] the move in SAN, e.g. "Nf3", "exd5", "O-O", "Ra8#"
|
|
42
|
+
#
|
|
43
|
+
def self.san(position, from, to, promotion = nil)
|
|
44
|
+
new(position).san(from, to, promotion)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Convenience: build SAN directly from a FEN string.
|
|
48
|
+
#
|
|
49
|
+
# @param fen [String] the FEN of the position *before* the move
|
|
50
|
+
# @param (see .san)
|
|
51
|
+
# @return [String] the move in SAN
|
|
52
|
+
#
|
|
53
|
+
def self.san_from_fen(fen, from, to, promotion = nil)
|
|
54
|
+
san(PGN::FEN.new(fen).to_position, from, to, promotion)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def initialize(position)
|
|
58
|
+
@position = position
|
|
59
|
+
@board = position.board
|
|
60
|
+
@player = position.player
|
|
61
|
+
@mover = (@player == :white ? 'w' : 'b')
|
|
62
|
+
@enemy = (@player == :white ? 'b' : 'w')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param (see .san)
|
|
66
|
+
# @return [String]
|
|
67
|
+
def san(from, to, promotion = nil)
|
|
68
|
+
from_idx = @board.index_of(from)
|
|
69
|
+
to_idx = @board.index_of(to)
|
|
70
|
+
piece = @board.at_index(from_idx)
|
|
71
|
+
raise ArgumentError, "no piece on #{from}" if piece.nil?
|
|
72
|
+
|
|
73
|
+
promotion = promotion.upcase if promotion
|
|
74
|
+
|
|
75
|
+
castling = piece.upcase == 'K' && ((from_idx & 0x0F) - (to_idx & 0x0F)).abs == 2
|
|
76
|
+
capture = castling ? false : capture?(piece, from_idx, to_idx)
|
|
77
|
+
|
|
78
|
+
body =
|
|
79
|
+
if castling
|
|
80
|
+
to_idx < from_idx ? 'O-O-O' : 'O-O'
|
|
81
|
+
elsif piece.upcase == 'P'
|
|
82
|
+
pawn_body(from_idx, to_idx, capture, promotion)
|
|
83
|
+
else
|
|
84
|
+
piece_body(piece, from_idx, to_idx, capture)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
body + suffix(from_idx, to_idx, piece, promotion, castling)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# -- body construction -------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def pawn_body(from_idx, to_idx, capture, promotion)
|
|
95
|
+
dest = @board.position_for([to_idx & 0x0F, to_idx >> 4])
|
|
96
|
+
promo = promotion ? "=#{promotion}" : ''
|
|
97
|
+
if capture
|
|
98
|
+
"#{Board::INDEX_TO_FILE[from_idx & 0x0F]}x#{dest}#{promo}"
|
|
99
|
+
else
|
|
100
|
+
"#{dest}#{promo}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def piece_body(piece, from_idx, to_idx, capture)
|
|
105
|
+
disamb = disambiguation(piece, from_idx, to_idx)
|
|
106
|
+
dest = @board.position_for([to_idx & 0x0F, to_idx >> 4])
|
|
107
|
+
parts = [piece.upcase, disamb]
|
|
108
|
+
parts << 'x' if capture
|
|
109
|
+
parts << dest
|
|
110
|
+
parts.join
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Disambiguate a non-pawn, non-castling move among same-type pieces that
|
|
114
|
+
# can *legally* reach the destination. Returns '' when none can.
|
|
115
|
+
def disambiguation(piece, from_idx, to_idx)
|
|
116
|
+
others = same_type_legals(piece, from_idx, to_idx)
|
|
117
|
+
return '' if others.empty?
|
|
118
|
+
|
|
119
|
+
from_file = from_idx & 0x0F
|
|
120
|
+
from_rank = from_idx >> 4
|
|
121
|
+
file_clash = others.any? { |o| (o & 0x0F) == from_file }
|
|
122
|
+
rank_clash = others.any? { |o| (o >> 4) == from_rank }
|
|
123
|
+
|
|
124
|
+
if !file_clash
|
|
125
|
+
Board::INDEX_TO_FILE[from_file]
|
|
126
|
+
elsif !rank_clash
|
|
127
|
+
Board::INDEX_TO_RANK[from_rank]
|
|
128
|
+
else
|
|
129
|
+
Board::INDEX_TO_FILE[from_file] + Board::INDEX_TO_RANK[from_rank]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Other squares holding `piece` whose move to `to_idx` is legal.
|
|
134
|
+
def same_type_legals(piece, from_idx, to_idx)
|
|
135
|
+
(0...128).each_with_object([]) do |idx, acc|
|
|
136
|
+
next if (idx & 0x88) != 0
|
|
137
|
+
next if idx == from_idx
|
|
138
|
+
next unless @board.at_index(idx) == piece
|
|
139
|
+
|
|
140
|
+
acc << idx if reaches?(piece, idx, to_idx) && legal_after?(idx, to_idx, piece)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# -- check / checkmate suffix -----------------------------------------
|
|
145
|
+
|
|
146
|
+
def suffix(from_idx, to_idx, piece, promotion, castling)
|
|
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)
|
|
150
|
+
|
|
151
|
+
result_pos = PGN::Position.new(
|
|
152
|
+
nb,
|
|
153
|
+
@player == :white ? :black : :white,
|
|
154
|
+
[],
|
|
155
|
+
new_ep_square(piece, from_idx, to_idx),
|
|
156
|
+
0,
|
|
157
|
+
@position.fullmove
|
|
158
|
+
)
|
|
159
|
+
any_legal_move?(result_pos) ? '+' : '#'
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# -- move application -------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def apply_move(from_idx, to_idx, piece, promotion, castling)
|
|
165
|
+
nb = @board.dup
|
|
166
|
+
return castle!(nb, from_idx, to_idx, piece) if castling
|
|
167
|
+
|
|
168
|
+
ep = ep_capture_square(piece, from_idx, to_idx)
|
|
169
|
+
nb.update_index(ep, nil) if ep
|
|
170
|
+
nb.update_index(from_idx, nil)
|
|
171
|
+
nb.update_index(to_idx, promotion ? promoted(promotion) : piece)
|
|
172
|
+
nb
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def castle!(board, from_idx, to_idx, piece)
|
|
176
|
+
rank = from_idx >> 4
|
|
177
|
+
rook = piece.upcase == 'K' ? 'R' : 'r'
|
|
178
|
+
board.update_index(from_idx, nil)
|
|
179
|
+
board.update_index(to_idx, piece)
|
|
180
|
+
if to_idx < from_idx # queenside
|
|
181
|
+
board.update_index(rank * 16, nil)
|
|
182
|
+
board.update_index((rank * 16) + 3, rook)
|
|
183
|
+
else # kingside
|
|
184
|
+
board.update_index((rank * 16) + 7, nil)
|
|
185
|
+
board.update_index((rank * 16) + 5, rook)
|
|
186
|
+
end
|
|
187
|
+
board
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def promoted(promotion)
|
|
191
|
+
@player == :white ? promotion.upcase : promotion.downcase
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# En-passant captured-pawn square (or nil): a pawn moving diagonally to
|
|
195
|
+
# an empty destination must be capturing en passant.
|
|
196
|
+
def ep_capture_square(piece, from_idx, to_idx)
|
|
197
|
+
return nil unless piece.upcase == 'P'
|
|
198
|
+
return nil if (from_idx & 0x0F) == (to_idx & 0x0F)
|
|
199
|
+
|
|
200
|
+
@board.at_index(to_idx).nil? ? ((from_idx >> 4) * 16) + (to_idx & 0x0F) : nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The en-passant target square left behind by a double pawn push, for the
|
|
204
|
+
# resulting position (so an escaping ep capture is considered for mate).
|
|
205
|
+
def new_ep_square(piece, from_idx, to_idx)
|
|
206
|
+
return nil unless piece.upcase == 'P'
|
|
207
|
+
return nil if ((from_idx >> 4) - (to_idx >> 4)).abs != 2
|
|
208
|
+
|
|
209
|
+
mid = ((from_idx >> 4) + (to_idx >> 4)) / 2
|
|
210
|
+
Board::INDEX_TO_FILE[from_idx & 0x0F] + Board::INDEX_TO_RANK[mid]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# -- legality ---------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
# Is `piece` from `from_idx` to `to_idx` legal (own king safe after)?
|
|
216
|
+
def legal_after?(from_idx, to_idx, piece)
|
|
217
|
+
nb = @board.dup
|
|
218
|
+
ep = ep_capture_square(piece, from_idx, to_idx)
|
|
219
|
+
nb.update_index(ep, nil) if ep
|
|
220
|
+
nb.update_index(from_idx, nil)
|
|
221
|
+
nb.update_index(to_idx, piece)
|
|
222
|
+
king = king_idx(nb, @mover)
|
|
223
|
+
king && !attacked?(nb, king, @enemy)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Does `piece` at `from_idx` pseudo-legally reach `to_idx` on the board?
|
|
227
|
+
# (Sliders honor blockers; the destination may be empty or enemy-occupied.)
|
|
228
|
+
def reaches?(piece, from_idx, to_idx)
|
|
229
|
+
return false if from_idx == to_idx
|
|
230
|
+
|
|
231
|
+
case piece.upcase
|
|
232
|
+
when 'N'
|
|
233
|
+
KNIGHT_OFFS.any? { |o| (from_idx + o) == to_idx }
|
|
234
|
+
when 'K'
|
|
235
|
+
KING_OFFS.any? { |o| (from_idx + o) == to_idx }
|
|
236
|
+
when 'B', 'R', 'Q'
|
|
237
|
+
slider_reaches?(piece.upcase, from_idx, to_idx)
|
|
238
|
+
else
|
|
239
|
+
false
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def slider_reaches?(piece_up, from_idx, to_idx)
|
|
244
|
+
slider_dirs(piece_up).any? do |off|
|
|
245
|
+
i = from_idx + off
|
|
246
|
+
while (i & 0x88).zero?
|
|
247
|
+
return true if i == to_idx
|
|
248
|
+
break if @board.at_index(i)
|
|
249
|
+
|
|
250
|
+
i += off
|
|
251
|
+
end
|
|
252
|
+
false
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Capture detection for the *played* move (used in the SAN body).
|
|
257
|
+
def capture?(piece, from_idx, to_idx)
|
|
258
|
+
if piece.upcase == 'P'
|
|
259
|
+
(from_idx & 0x0F) != (to_idx & 0x0F) # any diagonal pawn move captures
|
|
260
|
+
else
|
|
261
|
+
target = @board.at_index(to_idx)
|
|
262
|
+
target && !own?(target, @mover)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def own?(piece, color)
|
|
267
|
+
color == 'w' ? piece == piece.upcase : piece == piece.downcase
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Slider ray directions for a piece letter ('B'/'R'/'Q').
|
|
271
|
+
def slider_dirs(piece_up)
|
|
272
|
+
case piece_up
|
|
273
|
+
when 'B' then BISHOP_DIRS
|
|
274
|
+
when 'R' then ROOK_DIRS
|
|
275
|
+
else BISHOP_DIRS + ROOK_DIRS
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# -- attack / legality helpers ----------------------------------------
|
|
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
|
|
346
|
+
|
|
347
|
+
# -- legal-move generation (for checkmate detection) ------------------
|
|
348
|
+
|
|
349
|
+
def any_legal_move?(position)
|
|
350
|
+
board = position.board
|
|
351
|
+
color = position.player == :white ? 'w' : 'b'
|
|
352
|
+
ep_idx = ep_target_idx(position, board)
|
|
353
|
+
|
|
354
|
+
(0...128).any? do |idx|
|
|
355
|
+
next if (idx & 0x88) != 0
|
|
356
|
+
|
|
357
|
+
piece = board.at_index(idx)
|
|
358
|
+
piece && own?(piece, color) && move_from?(board, idx, piece, color, ep_idx)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def ep_target_idx(position, board)
|
|
363
|
+
ep = position.en_passant
|
|
364
|
+
return nil if ep.nil? || ep == '-' || ep.empty?
|
|
365
|
+
|
|
366
|
+
board.index_of(ep)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Yields true if `piece` at `idx` has any legal move (escapes check).
|
|
370
|
+
def move_from?(board, idx, piece, color, ep_idx)
|
|
371
|
+
up = piece.upcase
|
|
372
|
+
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)
|
|
375
|
+
when 'B', 'R', 'Q'
|
|
376
|
+
slider_moves?(board, idx, piece, color, slider_dirs(up))
|
|
377
|
+
when 'P'
|
|
378
|
+
pawn_moves?(board, idx, piece, color, ep_idx)
|
|
379
|
+
else
|
|
380
|
+
false
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
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)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def slider_moves?(board, idx, piece, color, dirs)
|
|
392
|
+
dirs.any? do |off|
|
|
393
|
+
t = idx + off
|
|
394
|
+
while (t & 0x88).zero?
|
|
395
|
+
tp = board.at_index(t)
|
|
396
|
+
if tp.nil?
|
|
397
|
+
return true if safe?(board, idx, t, piece, nil)
|
|
398
|
+
else
|
|
399
|
+
return true if !own?(tp, color) && safe?(board, idx, t, piece, nil)
|
|
400
|
+
break
|
|
401
|
+
end
|
|
402
|
+
t += off
|
|
403
|
+
end
|
|
404
|
+
false
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def pawn_moves?(board, idx, piece, color, ep_idx)
|
|
409
|
+
pawn_pushes?(board, idx, piece, color) ||
|
|
410
|
+
pawn_captures?(board, idx, piece, color, ep_idx)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def pawn_pushes?(board, idx, piece, color)
|
|
414
|
+
dir = color == 'w' ? 16 : -16
|
|
415
|
+
start_rank = color == 'w' ? 1 : 6
|
|
416
|
+
promo_rank = color == 'w' ? 7 : 0
|
|
417
|
+
|
|
418
|
+
t = idx + dir
|
|
419
|
+
return false unless (t & 0x88).zero? && board.at_index(t).nil?
|
|
420
|
+
|
|
421
|
+
promo = (t >> 4 == promo_rank) ? 'Q' : nil
|
|
422
|
+
return true if safe?(board, idx, t, piece, promo)
|
|
423
|
+
|
|
424
|
+
t2 = idx + (dir * 2)
|
|
425
|
+
(idx >> 4) == start_rank && board.at_index(t2).nil? && safe?(board, idx, t2, piece, nil)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def pawn_captures?(board, idx, piece, color, ep_idx) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
429
|
+
promo_rank = color == 'w' ? 7 : 0
|
|
430
|
+
caps = color == 'w' ? [15, 17] : [-15, -17]
|
|
431
|
+
caps.any? do |off|
|
|
432
|
+
t = idx + off
|
|
433
|
+
next false unless (t & 0x88).zero?
|
|
434
|
+
|
|
435
|
+
tp = board.at_index(t)
|
|
436
|
+
promo = (t >> 4 == promo_rank) ? 'Q' : nil
|
|
437
|
+
if tp && !own?(tp, color)
|
|
438
|
+
safe?(board, idx, t, piece, promo)
|
|
439
|
+
elsif tp.nil? && t == ep_idx
|
|
440
|
+
safe?(board, idx, t, piece, nil)
|
|
441
|
+
else
|
|
442
|
+
false
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def landable?(board, target, color)
|
|
448
|
+
tp = board.at_index(target)
|
|
449
|
+
tp.nil? || !own?(tp, color)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Apply a pseudo-move (with ep + promotion) on a copy and test own-king
|
|
453
|
+
# safety. The moving side is derived from `piece`'s case.
|
|
454
|
+
def safe?(board, from_idx, to_idx, piece, promotion)
|
|
455
|
+
nb = board.dup
|
|
456
|
+
if piece.upcase == 'P' && (from_idx & 0x0F) != (to_idx & 0x0F) && board.at_index(to_idx).nil?
|
|
457
|
+
nb.update_index(((from_idx >> 4) * 16) + (to_idx & 0x0F), nil) # ep capture
|
|
458
|
+
end
|
|
459
|
+
nb.update_index(from_idx, nil)
|
|
460
|
+
nb.update_index(to_idx, place_piece(piece, promotion))
|
|
461
|
+
color = own?(piece, 'w') ? 'w' : 'b'
|
|
462
|
+
king = king_idx(nb, color)
|
|
463
|
+
king && !attacked?(nb, king, color == 'w' ? 'b' : 'w')
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# The piece letter to place on the destination after a pseudo-move.
|
|
467
|
+
def place_piece(piece, promotion)
|
|
468
|
+
return piece unless promotion
|
|
469
|
+
|
|
470
|
+
white = piece == piece.upcase
|
|
471
|
+
white ? promotion.upcase : promotion.downcase
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
data/lib/pgn/pgn_parser.rb
CHANGED
|
@@ -21,8 +21,10 @@ module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 97)
|
|
|
21
21
|
def next_token
|
|
22
22
|
pair = @lexer.next_token_pair
|
|
23
23
|
return [false, false] unless pair
|
|
24
|
-
type, value
|
|
25
|
-
|
|
24
|
+
# Mutate the lexer's [type, value] pair in place rather than allocating
|
|
25
|
+
# a second translated pair -- one array per token is the Racc floor.
|
|
26
|
+
pair[0] = translate_type(pair[0])
|
|
27
|
+
pair
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
private
|
data/lib/pgn/pgn_parser.y
CHANGED
|
@@ -106,8 +106,10 @@ rule
|
|
|
106
106
|
def next_token
|
|
107
107
|
pair = @lexer.next_token_pair
|
|
108
108
|
return [false, false] unless pair
|
|
109
|
-
type, value
|
|
110
|
-
|
|
109
|
+
# Mutate the lexer's [type, value] pair in place rather than allocating
|
|
110
|
+
# a second translated pair -- one array per token is the Racc floor.
|
|
111
|
+
pair[0] = translate_type(pair[0])
|
|
112
|
+
pair
|
|
111
113
|
end
|
|
112
114
|
|
|
113
115
|
private
|
data/lib/pgn/version.rb
CHANGED
data/lib/pgn.rb
CHANGED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
# Helper: build a Position from a FEN string.
|
|
6
|
+
def pos(fen)
|
|
7
|
+
PGN::FEN.new(fen).to_position
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
describe PGN::Notation do
|
|
11
|
+
# ------------------------------------------------------------------
|
|
12
|
+
# Public API
|
|
13
|
+
# ------------------------------------------------------------------
|
|
14
|
+
describe '.san' do
|
|
15
|
+
it 'generates a plain pawn push from coordinates' do
|
|
16
|
+
expect(PGN::Notation.san(pos('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
|
|
17
|
+
'e2', 'e4')).to eq('e4')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'generates a knight move with the piece letter' do
|
|
21
|
+
expect(PGN::Notation.san(pos('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
|
|
22
|
+
'g1', 'f3')).to eq('Nf3')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'generates a pawn capture with the origin file' do
|
|
26
|
+
fen = 'rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 2'
|
|
27
|
+
expect(PGN::Notation.san(pos(fen), 'e4', 'd5')).to eq('exd5')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'generates an en passant capture' do
|
|
31
|
+
fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
|
|
32
|
+
expect(PGN::Notation.san(pos(fen), 'e5', 'd6')).to eq('exd6')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'generates a pawn promotion' do
|
|
36
|
+
fen = '8/P7/6k1/8/8/8/8/4K3 w - - 0 1'
|
|
37
|
+
expect(PGN::Notation.san(pos(fen), 'a7', 'a8', 'q')).to eq('a8=Q')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'generates a capture promotion' do
|
|
41
|
+
fen = '1n6/P7/6k1/8/8/8/8/4K3 w - - 0 1'
|
|
42
|
+
expect(PGN::Notation.san(pos(fen), 'a7', 'b8', 'q')).to eq('axb8=Q')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'generates kingside castling' do
|
|
46
|
+
fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
|
|
47
|
+
expect(PGN::Notation.san(pos(fen), 'e1', 'g1')).to eq('O-O')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'generates queenside castling' do
|
|
51
|
+
fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
|
|
52
|
+
expect(PGN::Notation.san(pos(fen), 'e1', 'c1')).to eq('O-O-O')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'generates kingside castling for black' do
|
|
56
|
+
fen = 'r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1'
|
|
57
|
+
expect(PGN::Notation.san(pos(fen), 'e8', 'g8')).to eq('O-O')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe '.san disambiguation' do
|
|
62
|
+
it 'disambiguates knights by file' do
|
|
63
|
+
# Knights on c3 and d4 both reach b5.
|
|
64
|
+
fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
|
|
65
|
+
expect(PGN::Notation.san(pos(fen), 'd4', 'b5')).to eq('Ndb5')
|
|
66
|
+
expect(PGN::Notation.san(pos(fen), 'c3', 'b5')).to eq('Ncb5')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'disambiguates rooks by rank when on the same file' do
|
|
70
|
+
# White rooks on a1 and a8 both reach a4 (file a clear).
|
|
71
|
+
fen = 'R7/8/8/4k3/8/8/8/R3K3 w - - 0 1'
|
|
72
|
+
expect(PGN::Notation.san(pos(fen), 'a1', 'a4')).to eq('R1a4')
|
|
73
|
+
expect(PGN::Notation.san(pos(fen), 'a8', 'a4')).to eq('R8a4')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'disambiguates queens by full square when file and rank both clash' do
|
|
77
|
+
# Queens on c1, f1, f4 all reach c4. The f1 queen shares file f with
|
|
78
|
+
# f4 and rank 1 with c1, so only the full square disambiguates.
|
|
79
|
+
fen = '7k/8/8/8/5Q2/8/8/2Q2Q1K w - - 0 1'
|
|
80
|
+
expect(PGN::Notation.san(pos(fen), 'f1', 'c4')).to eq('Qf1c4')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'omits disambiguation when only one piece can legally move there' do
|
|
84
|
+
# Two knights both pseudo-reach d2, but the e4 knight is pinned
|
|
85
|
+
# on the e-file (moving it exposes the king on e1 to the rook on e8),
|
|
86
|
+
# so only the b1 knight can legally play Nd2 — no disambiguation.
|
|
87
|
+
fen = '4r2k/8/8/8/4N3/8/8/1N2K3 w - - 0 1'
|
|
88
|
+
expect(PGN::Notation.san(pos(fen), 'b1', 'd2')).to eq('Nd2')
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe '.san check and checkmate suffixes' do
|
|
93
|
+
it 'appends + for a checking move' do
|
|
94
|
+
fen = '7k/8/8/8/8/8/6R1/6K1 w - - 0 1'
|
|
95
|
+
expect(PGN::Notation.san(pos(fen), 'g2', 'g8')).to eq('Rg8+')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'appends # for a checkmating move' do
|
|
99
|
+
fen = '6k1/5ppp/8/8/8/8/8/R6K w - - 0 1'
|
|
100
|
+
expect(PGN::Notation.san(pos(fen), 'a1', 'a8')).to eq('Ra8#')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'appends # to a promotion that gives checkmate' do
|
|
104
|
+
# White pawn b7 promotes with check on a king cornered on h8.
|
|
105
|
+
fen = '7k/1P6/8/8/8/8/8/6K1 w - - 0 1'
|
|
106
|
+
expect(PGN::Notation.san(pos(fen), 'b7', 'b8', 'q')).to eq('b8=Q+')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'does not append a suffix for a quiet move' do
|
|
110
|
+
fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
|
|
111
|
+
expect(PGN::Notation.san(pos(fen), 'd2', 'd4')).to eq('d4')
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe '.san_from_fen' do
|
|
116
|
+
it 'computes SAN directly from a FEN string' do
|
|
117
|
+
expect(PGN::Notation.san_from_fen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
|
118
|
+
'e2', 'e4')).to eq('e4')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'computes SAN for black to move' do
|
|
122
|
+
fen = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1'
|
|
123
|
+
expect(PGN::Notation.san_from_fen(fen, 'd7', 'd5')).to eq('d5')
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
metadata
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pgn2
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stacey Touset
|
|
8
8
|
- Murilo Vasconcelos
|
|
9
|
-
autorequire:
|
|
10
9
|
bindir: bin
|
|
11
10
|
cert_chain: []
|
|
12
|
-
date:
|
|
11
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
13
12
|
dependencies:
|
|
14
13
|
- !ruby/object:Gem::Dependency
|
|
15
14
|
name: bundler
|
|
@@ -168,6 +167,7 @@ files:
|
|
|
168
167
|
- lib/pgn/lexer.rb
|
|
169
168
|
- lib/pgn/move.rb
|
|
170
169
|
- lib/pgn/move_calculator.rb
|
|
170
|
+
- lib/pgn/notation.rb
|
|
171
171
|
- lib/pgn/parser.rb
|
|
172
172
|
- lib/pgn/pgn_parser.rb
|
|
173
173
|
- lib/pgn/pgn_parser.y
|
|
@@ -181,6 +181,7 @@ files:
|
|
|
181
181
|
- spec/lexer_spec.rb
|
|
182
182
|
- spec/move_calculator_spec.rb
|
|
183
183
|
- spec/move_spec.rb
|
|
184
|
+
- spec/notation_spec.rb
|
|
184
185
|
- spec/parser_explicit_spec.rb
|
|
185
186
|
- spec/parser_spec.rb
|
|
186
187
|
- spec/pgn_files/alternate_castling.pgn
|
|
@@ -208,7 +209,6 @@ metadata:
|
|
|
208
209
|
source_code_uri: https://github.com/muriloime/pgn
|
|
209
210
|
bug_tracker_uri: https://github.com/muriloime/pgn/issues
|
|
210
211
|
changelog_uri: https://github.com/muriloime/pgn/blob/main/CHANGELOG.md
|
|
211
|
-
post_install_message:
|
|
212
212
|
rdoc_options: []
|
|
213
213
|
require_paths:
|
|
214
214
|
- lib
|
|
@@ -223,8 +223,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
223
223
|
- !ruby/object:Gem::Version
|
|
224
224
|
version: '0'
|
|
225
225
|
requirements: []
|
|
226
|
-
rubygems_version:
|
|
227
|
-
signing_key:
|
|
226
|
+
rubygems_version: 4.0.10
|
|
228
227
|
specification_version: 4
|
|
229
228
|
summary: A PGN parser for Ruby
|
|
230
229
|
test_files:
|
|
@@ -234,6 +233,7 @@ test_files:
|
|
|
234
233
|
- spec/lexer_spec.rb
|
|
235
234
|
- spec/move_calculator_spec.rb
|
|
236
235
|
- spec/move_spec.rb
|
|
236
|
+
- spec/notation_spec.rb
|
|
237
237
|
- spec/parser_explicit_spec.rb
|
|
238
238
|
- spec/parser_spec.rb
|
|
239
239
|
- spec/pgn_files/alternate_castling.pgn
|