pgn2 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/pgn/move.rb ADDED
@@ -0,0 +1,163 @@
1
+ module PGN
2
+ # {PGN::Move} knows how to parse a move string in standard algebraic
3
+ # notation to extract all relevant information.
4
+ #
5
+ # @see http://en.wikipedia.org/wiki/Algebraic_notation_(chess) Standard
6
+ # Algebraic Notation
7
+ #
8
+ # @!attribute san
9
+ # @return [String] the move string
10
+ # @example
11
+ # move1.san #=> "O-O-O+"
12
+ # move2.san #=> "Raxe1"
13
+ # move3.san #=> "e8=Q#"
14
+ #
15
+ # @!attribute player
16
+ # @return [Symbol] the current player
17
+ # @example
18
+ # move.player #=> :white
19
+ #
20
+ # @!attribute piece
21
+ # @return [String, nil] the piece being moved
22
+ # @example
23
+ # move1.piece #=> "Q"
24
+ # move2.piece #=> "r"
25
+ # @note this is nil for castling
26
+ # @note uppercase represents white, lowercase represents black
27
+ #
28
+ # @!attribute destination
29
+ # @return [String, nil] the destination square of the piece
30
+ # @example
31
+ # move.destination #=> "e4"
32
+ #
33
+ # @!attribute promotion
34
+ # @return [String, nil] the promotion piece, if applicable
35
+ #
36
+ # @!attribute check
37
+ # @return [String, nil] whether the move results in check or mate
38
+ # @example
39
+ # move1.check #=> "+"
40
+ # move2.check #=> "#"
41
+ #
42
+ # @!attribute capture
43
+ # @return [String, nil] whether the move is a capture
44
+ # @example
45
+ # move.capture #=> "x"
46
+ #
47
+ # @!attribute disambiguation
48
+ # @return [String, nil] the disambiguation string if there is one
49
+ # @example
50
+ # move.disambiguation #=> "3"
51
+ #
52
+ # @!attribute castle
53
+ # @return [String, nil] the castle string if applicable
54
+ # @example
55
+ # move1.castle #=> "O-O-O"
56
+ # move2.castle #=> "O-O"
57
+ #
58
+
59
+ class Move
60
+ attr_accessor :san, :player
61
+ attr_accessor :piece, :destination, :promotion, :check, :capture, :disambiguation, :castle
62
+ # A regular expression for matching moves in standard algebraic
63
+ # notation
64
+ #
65
+ SAN_REGEX = /
66
+ (?<piece> [BKNQR] ){0}
67
+ (?<destination> [a-h][1-8] ){0}
68
+ (?<promotion> =[BNQR] ){0}
69
+ (?<check> [#+] ){0}
70
+ (?<capture> x ){0}
71
+ (?<disambiguation> [a-h]?[1-8]? ){0}
72
+
73
+ (?<castle> O-O(-O)? ){0}
74
+
75
+ (?<normal>
76
+ \g<piece>?
77
+ \g<disambiguation>
78
+ \g<capture>?
79
+ \g<destination>
80
+ \g<promotion>?
81
+ ){0}
82
+
83
+ \A (\g<castle> | \g<normal>) \g<check>? \z
84
+ /x.freeze
85
+
86
+ # @param move [String] the move in SAN
87
+ # @param player [Symbol] the player making the move
88
+ # @example
89
+ # PGN::Move.new("e4", :white)
90
+ #
91
+ def initialize(move, player)
92
+ self.player = player
93
+ self.san = move
94
+
95
+ match = move.match(SAN_REGEX)
96
+ return if match.nil?
97
+
98
+ match.names.each do |name|
99
+ send("#{name}=", match[name]) if respond_to?(name)
100
+ end
101
+ end
102
+
103
+ def piece=(val)
104
+ return if san.match('O-O')
105
+
106
+ val ||= 'P'
107
+ @piece = black? ? val.downcase : val
108
+ end
109
+
110
+ def promotion=(val)
111
+ if val
112
+ val.downcase! if black?
113
+ @promotion = val.delete('=')
114
+ end
115
+ end
116
+
117
+ def capture=(val)
118
+ @capture = !!val
119
+ end
120
+
121
+ def disambiguation=(val)
122
+ @disambiguation = (val == '' ? nil : val)
123
+ end
124
+
125
+ def castle=(val)
126
+ if val
127
+ @castle = 'K' if val == 'O-O'
128
+ @castle = 'Q' if val == 'O-O-O'
129
+ @castle.downcase! if black?
130
+ end
131
+ end
132
+
133
+ # @return [Boolean] whether the move results in check
134
+ #
135
+ def check?
136
+ check == '+'
137
+ end
138
+
139
+ # @return [Boolean] whether the move results in checkmate
140
+ #
141
+ def checkmate?
142
+ check == '#'
143
+ end
144
+
145
+ # @return [Boolean] whether it's white's turn
146
+ #
147
+ def white?
148
+ player == :white
149
+ end
150
+
151
+ # @return [Boolean] whether it's black's turn
152
+ #
153
+ def black?
154
+ player == :black
155
+ end
156
+
157
+ # @return [Boolean] whether the piece being moved is a pawn
158
+ #
159
+ def pawn?
160
+ %w[P p].include?(piece)
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,337 @@
1
+ module PGN
2
+ # {PGN::MoveCalculator} is responsible for computing all of the ways that a
3
+ # specific move changes the current position. This includes which squares on
4
+ # the board need to be updated, new castling restrictions, the en passant
5
+ # square and whether to update fullmove and halfmove counters.
6
+ #
7
+ # @!attribute board
8
+ # @return [PGN::Board] the current board
9
+ #
10
+ # @!attribute move
11
+ # @return [PGN::Move] the current move
12
+ #
13
+ # @!attribute origin
14
+ # @return [String, nil] the origin square in SAN
15
+ #
16
+ class MoveCalculator
17
+ # Specifies the movement of pieces who are allowed to move in a
18
+ # given direction until they reach an obstacle or the end of the
19
+ # board.
20
+ #
21
+ DIRECTIONS = {
22
+ 'b' => [[1, 1], [-1, 1], [-1, -1], [1, -1]],
23
+ 'r' => [[-1, 0], [1, 0], [0, -1], [0, 1]],
24
+ 'q' => [[1, 1], [-1, 1], [-1, -1], [1, -1],
25
+ [-1, 0], [1, 0], [0, -1], [0, 1]]
26
+ }.freeze
27
+
28
+ # Specifies the movement of pieces that have a limited set of moves
29
+ # they are allowed to make.
30
+ #
31
+ MOVES = {
32
+ 'k' => [[-1, -1], [0, -1], [1, -1], [1, 0],
33
+ [1, 1], [0, 1], [-1, 1], [-1, 0]],
34
+ 'n' => [[-1, -2], [-1, 2], [1, -2], [1, 2],
35
+ [-2, -1], [2, -1], [-2, 1], [2, 1]]
36
+ }.freeze
37
+
38
+ # Specifies possible pawn movements. It may seem backwards since it is
39
+ # used to compute the origin square and not the destination.
40
+ #
41
+ PAWN_MOVES = {
42
+ 'P' => {
43
+ capture: [[-1, -1], [1, -1]],
44
+ normal: [[0, -1]],
45
+ double: [[0, -2]]
46
+ },
47
+ 'p' => {
48
+ capture: [[-1, 1], [1, 1]],
49
+ normal: [[0, 1]],
50
+ double: [[0, 2]]
51
+ }
52
+ }.freeze
53
+
54
+ # The squares to update for each possible castling move.
55
+ #
56
+ CASTLING = {
57
+ 'Q' => {
58
+ 'a1' => nil,
59
+ 'c1' => 'K',
60
+ 'd1' => 'R',
61
+ 'e1' => nil
62
+ },
63
+ 'K' => {
64
+ 'e1' => nil,
65
+ 'f1' => 'R',
66
+ 'g1' => 'K',
67
+ 'h1' => nil
68
+ },
69
+ 'q' => {
70
+ 'a8' => nil,
71
+ 'c8' => 'k',
72
+ 'd8' => 'r',
73
+ 'e8' => nil
74
+ },
75
+ 'k' => {
76
+ 'e8' => nil,
77
+ 'f8' => 'r',
78
+ 'g8' => 'k',
79
+ 'h8' => nil
80
+ }
81
+ }.freeze
82
+
83
+ attr_accessor :board
84
+ attr_accessor :move
85
+ attr_accessor :origin
86
+
87
+ # @param board [PGN::Board] the current board
88
+ # @param move [PGN::Move] the current move
89
+ #
90
+ def initialize(board, move)
91
+ self.board = board
92
+ self.move = move
93
+ self.origin = compute_origin
94
+ end
95
+
96
+ # @return [PGN::Board] the board after the move is made
97
+ #
98
+ def result_board
99
+ new_board = board.dup
100
+ new_board.change!(changes)
101
+
102
+ new_board
103
+ end
104
+
105
+ # @return [Array<String>] which castling moves are no longer available
106
+ #
107
+ def castling_restrictions
108
+ restrict = []
109
+
110
+ # when a king or rook is moved
111
+ case move.piece
112
+ when 'K'
113
+ restrict += %w[K Q]
114
+ when 'k'
115
+ restrict += %w[k q]
116
+ when 'R'
117
+ restrict << { 'a1' => 'Q', 'h1' => 'K' }[origin]
118
+ when 'r'
119
+ restrict << { 'a8' => 'q', 'h8' => 'k' }[origin]
120
+ end
121
+
122
+ # when castling occurs
123
+ restrict += %w[K Q] if %w[K Q].include? move.castle
124
+ restrict += %w[k q] if %w[k q].include? move.castle
125
+
126
+ # when a rook is taken
127
+ restrict << 'Q' if move.destination == 'a1'
128
+ restrict << 'q' if move.destination == 'a8'
129
+ restrict << 'K' if move.destination == 'h1'
130
+ restrict << 'k' if move.destination == 'h8'
131
+
132
+ restrict.compact.uniq
133
+ end
134
+
135
+ # @return [Boolean] whether to increment the halfmove clock
136
+ #
137
+ def increment_halfmove?
138
+ !(move.capture || move.pawn?)
139
+ end
140
+
141
+ # @return [Boolean] whether to increment the fullmove counter
142
+ #
143
+ def increment_fullmove?
144
+ move.black?
145
+ end
146
+
147
+ # @return [String, nil] the en passant square if applicable
148
+ #
149
+ def en_passant_square
150
+ return nil if move.castle
151
+
152
+ if move.pawn? && (origin[1].to_i - move.destination[1].to_i).abs == 2
153
+ if move.white?
154
+ origin[0] + '3'
155
+ else
156
+ origin[0] + '6'
157
+ end
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ def changes
164
+ changes = {}
165
+ changes.merge!(CASTLING[move.castle]) if move.castle
166
+ changes.merge!(
167
+ origin => nil,
168
+ move.destination => move.piece,
169
+ en_passant_capture => nil
170
+ )
171
+ changes[move.destination] = move.promotion if move.promotion
172
+
173
+ changes.reject! { |key, _| key.nil? or key.empty? }
174
+
175
+ changes
176
+ end
177
+
178
+ # Using the current position and move, figure out where the piece
179
+ # came from.
180
+ #
181
+ def compute_origin
182
+ return nil if move.castle
183
+
184
+ possibilities = case move.piece
185
+ when /[brq]/i then direction_origins
186
+ when /[kn]/i then move_origins
187
+ when /p/i then pawn_origins
188
+ else # don't care move, used in variations
189
+ return nil
190
+ end
191
+
192
+ possibilities = disambiguate(possibilities) if possibilities.length > 1
193
+
194
+ board.position_for(possibilities.first)
195
+ end
196
+
197
+ # From the destination square, move in each direction stopping if we
198
+ # reach the end of the board. If we encounter a piece, add it to the
199
+ # list of origin possibilities if it is the moving piece, or else
200
+ # check the next direction.
201
+ #
202
+ def direction_origins
203
+ directions = DIRECTIONS[move.piece.downcase]
204
+ possibilities = []
205
+
206
+ directions.each do |dir|
207
+ piece, square = first_piece(destination_coords, dir)
208
+ possibilities << square if piece == move.piece
209
+ end
210
+
211
+ possibilities
212
+ end
213
+
214
+ # From the destination square, make each move. If it is a valid
215
+ # square and matches the moving piece, add it to the list of origin
216
+ # possibilities.
217
+ #
218
+ def move_origins(moves = nil)
219
+ moves ||= MOVES[move.piece.downcase]
220
+ possibilities = []
221
+ file, rank = destination_coords
222
+
223
+ moves.each do |i, j|
224
+ f = file + i
225
+ r = rank + j
226
+
227
+ possibilities << [f, r] if valid_square?(f, r) && board.at(f, r) == move.piece
228
+ end
229
+
230
+ possibilities
231
+ end
232
+
233
+ # Computes the possbile pawn origins based on the destination square
234
+ # and whether or not the move is a capture.
235
+ #
236
+ def pawn_origins
237
+ _, rank = destination_coords
238
+ double_rank = (rank == 3 && move.white?) || (rank == 4 && move.black?)
239
+
240
+ pawn_moves = PAWN_MOVES[move.piece]
241
+
242
+ moves = move.capture ? pawn_moves[:capture] : pawn_moves[:normal]
243
+ moves += pawn_moves[:double] if double_rank
244
+
245
+ move_origins(moves)
246
+ end
247
+
248
+ def disambiguate(possibilities)
249
+ possibilities = disambiguate_san(possibilities)
250
+ possibilities = disambiguate_pawns(possibilities) if possibilities.length > 1
251
+ possibilities = disambiguate_discovered_check(possibilities) if possibilities.length > 1
252
+
253
+ possibilities
254
+ end
255
+
256
+ # Try to disambiguate based on the standard algebraic notation.
257
+ #
258
+ def disambiguate_san(possibilities)
259
+ if move.disambiguation
260
+ possibilities.select { |p| board.position_for(p).match(move.disambiguation) }
261
+ else
262
+ possibilities
263
+ end
264
+ end
265
+
266
+ # A pawn can't move two spaces if there is a pawn in front of it.
267
+ #
268
+ def disambiguate_pawns(possibilities)
269
+ if move.piece.match(/p/i) && !move.capture
270
+ possibilities.reject { |p| board.position_for(p).match(/2|7/) }
271
+ else
272
+ possibilities
273
+ end
274
+ end
275
+
276
+ # A piece can't move if it would result in a discovered check.
277
+ #
278
+ def disambiguate_discovered_check(possibilities)
279
+ DIRECTIONS.each do |attacking_piece, directions|
280
+ attacking_piece = attacking_piece.upcase if move.black?
281
+
282
+ directions.each do |dir|
283
+ piece, square = first_piece(king_position, dir)
284
+ next unless piece == move.piece && possibilities.include?(square)
285
+
286
+ piece, = first_piece(square, dir)
287
+ possibilities.reject! { |p| p == square } if piece == attacking_piece
288
+ end
289
+ end
290
+
291
+ possibilities
292
+ end
293
+
294
+ def first_piece(from, direction)
295
+ file, rank = from
296
+ i, j = direction
297
+
298
+ piece = nil
299
+
300
+ while valid_square?(file += i, rank += j)
301
+ break if piece = board.at(file, rank)
302
+ end
303
+
304
+ [piece, [file, rank]]
305
+ end
306
+
307
+ # If the move is a capture and there is no piece on the
308
+ # destination square, it must be an en passant capture.
309
+ #
310
+ def en_passant_capture
311
+ return nil if move.castle
312
+
313
+ move.destination[0] + origin[1] if !board.at(move.destination) && move.capture
314
+ end
315
+
316
+ def king_position
317
+ king = move.white? ? 'K' : 'k'
318
+
319
+ coords = nil
320
+ 0.upto(7) do |file|
321
+ 0.upto(7) do |rank|
322
+ coords = [file, rank] if board.at(file, rank) == king
323
+ end
324
+ end
325
+
326
+ coords
327
+ end
328
+
329
+ def valid_square?(file, rank)
330
+ (0..7) === file && (0..7) === rank
331
+ end
332
+
333
+ def destination_coords
334
+ board.coordinates_for(move.destination)
335
+ end
336
+ end
337
+ end
data/lib/pgn/parser.rb ADDED
@@ -0,0 +1,208 @@
1
+ require 'whittle'
2
+
3
+ module PGN
4
+ # {PGN::Parser} uses the whittle gem to parse pgn files based on their
5
+ # context free grammar.
6
+ #
7
+ class Parser < Whittle::Parser
8
+ def lex(input)
9
+ line = 1
10
+ offset = 0
11
+ ending = input.length
12
+ @@pgn ||= ''
13
+ @@game_comment ||= nil
14
+
15
+ until offset == ending
16
+ next_token(input, offset, line).tap do |token|
17
+ if !token.nil?
18
+ token[:offset] = offset
19
+ line, token[:line] = token[:line], line
20
+ yield token unless token[:discarded]
21
+ @@pgn += token[:value]
22
+ offset += token[:value].length
23
+ else
24
+ raise Whittle::UnconsumedInputError,
25
+ "Unmatched input #{input[offset..-1].inspect} on line #{line}"
26
+ # offset += 1
27
+ end
28
+ end
29
+ end
30
+
31
+ yield ({ name: :$end, line: line, value: nil, offset: offset })
32
+ end
33
+
34
+ rule(wsp: /\s+/).skip!
35
+ rule(
36
+ pgn_comment: /\A% .*/x
37
+ ).skip!
38
+
39
+ rule('[')
40
+ rule(']')
41
+ rule('(')
42
+ rule(')')
43
+
44
+ start(:pgn_database)
45
+
46
+ rule(:pgn_database) do |r|
47
+ r[].as { [] }
48
+ r[:pgn_comment, :pgn_database].as { |_title, db| db }
49
+ r[:pgn_database, :pgn_game].as { |database, game| database << game }
50
+ end
51
+
52
+ rule(:pgn_game) do |r|
53
+ r[:tag_section, :movetext_section].as do |tags, moves|
54
+ old_pgn = @@pgn
55
+ @@pgn = ''
56
+ comment = @@game_comment
57
+ @@game_comment = nil
58
+ { tags: tags, result: moves.pop, moves: moves, pgn: old_pgn, comment: comment }
59
+ end
60
+ end
61
+
62
+ rule(:tag_section) do |r|
63
+ r[:tag_pair, :tag_section].as { |pair, section| section.merge(pair) }
64
+ r[:tag_pair]
65
+ end
66
+
67
+ rule(:tag_pair) do |r|
68
+ r['[', :tag_name, :tag_value, ']'].as { |_, a, b, _| { a => b } }
69
+ end
70
+
71
+ rule(:tag_value) do |r|
72
+ r[:string].as { |value| value[1...-1] }
73
+ end
74
+
75
+ rule(:movetext_section) do |r|
76
+ r[:element_sequence, :game_termination].as { |a, b| a << b }
77
+ end
78
+
79
+ rule(:element_sequence) do |r|
80
+ r[:element_sequence, :element].as do |sequence, element|
81
+ element.nil? ? sequence : sequence << element
82
+ end
83
+ r[].as { [] }
84
+ end
85
+
86
+ rule(:element) do |r|
87
+ r[:move_number_indication].as { nil }
88
+ r[:san_move_annotated]
89
+ r[:san_move_annotated, :variation_list].as do |move, variations|
90
+ move.variations = variations
91
+ move
92
+ end
93
+ r[:comment].as { |c| @@game_comment = c; nil }
94
+ end
95
+
96
+ rule(:san_move_annotated) do |r|
97
+ r[:san_move].as { |move| MoveText.new(move) }
98
+ r[:san_move, :comment].as do |move, comment|
99
+ MoveText.new(move, nil, comment)
100
+ end
101
+ r[:san_move, :annotation_list].as do |move, annotation|
102
+ MoveText.new(move, annotation)
103
+ end
104
+ r[:san_move, :annotation_list, :comment].as do |move, annotation, comment|
105
+ MoveText.new(move, annotation, comment)
106
+ end
107
+ r[:san_move, :comment, :annotation_list].as do |move, comment, annotation|
108
+ MoveText.new(move, annotation, comment)
109
+ end
110
+ end
111
+
112
+ rule(:annotation_list) do |r|
113
+ r[:annotation_list, :numeric_annotation_glyph].as do |sequence, element|
114
+ element.nil? ? sequence : sequence << element
115
+ end
116
+ r[:numeric_annotation_glyph].as { |v| [v] }
117
+ end
118
+
119
+ rule(:variation_list) do |r|
120
+ r[:variation, :variation_list].as do |variation, sequence|
121
+ sequence << variation
122
+ end
123
+ r[:variation].as { |v| [v] }
124
+ end
125
+
126
+ rule(:variation) do |r|
127
+ r['(', :element_sequence, ')'].as { |_, sequence, _| sequence }
128
+ end
129
+
130
+ rule(
131
+ string: /
132
+ " # beginning of string
133
+ (
134
+ [[:print:]&&[^\\"]] | # printing characters except quote and backslash
135
+ \\\\ | # escaped backslashes
136
+ \\" # escaped quotation marks
137
+ )* # zero or more of the above
138
+ " # end of string
139
+ /x
140
+ )
141
+
142
+ rule(
143
+ comment: /
144
+ (
145
+ \{ # beginning of comment
146
+ (
147
+ [[:print:]&&[^\\\{\}]] | # printing characters except brace and backslash
148
+ \n |
149
+ \\\\ | # escaped backslashes
150
+ \\\{|\\\} | # escaped braces
151
+ \n | # newlines
152
+ \g<1> # recursive
153
+ )* # zero or more of the above
154
+ \} # end of comment
155
+ )
156
+ /x
157
+ )
158
+
159
+ rule(
160
+ game_termination: %r{
161
+ 1-0 | # white wins
162
+ 0-1 | # black wins
163
+ 1\/2-1\/2 | # draw
164
+ \* # ?
165
+ }x
166
+ )
167
+
168
+ rule(
169
+ move_number_indication: /
170
+ [[:digit:]]+\.* # one or more digits followed by zero or more periods
171
+ /x
172
+ )
173
+
174
+ rule(
175
+ san_move: %r{
176
+ (
177
+ -- | # "don't care" move (used in variations)
178
+ [O0](-[O0]){1,2} | # castling (O-O, O-O-O)
179
+ [a-h][1-8] | # pawn moves (e4, d7)
180
+ [BKNQR][a-h1-8]?x?[a-h][1-8] | # major piece moves w/ optional specifier
181
+ # and capture
182
+ # (Bd2, N4c3, Raxc1)
183
+ [a-h][1-8]?x[a-h][1-8] # pawn captures
184
+ )
185
+ (
186
+ =[BNQR] # optional promotion (d8=Q)
187
+ )?
188
+ (
189
+ \+ | # check (g5+)
190
+ \# # checkmate (Qe7#)
191
+ )?
192
+ }x
193
+ )
194
+
195
+ rule(
196
+ tag_name: /
197
+ [A-Za-z0-9_]+ # letters, digits and underscores only
198
+ /x
199
+ )
200
+
201
+ rule(
202
+ numeric_annotation_glyph: /
203
+ \$\d+ | # dollar sign followed by an integer from 0 to 255
204
+ [\?!][\?!]? # support the most used annotations directly
205
+ /x
206
+ )
207
+ end
208
+ end