pgn3 0.0.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.
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