pgn 0.0.1

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/game.rb ADDED
@@ -0,0 +1,82 @@
1
+ module PGN
2
+ # {PGN::Game} holds all of the information about a game. It is either
3
+ # the result of parsing a PGN file, or created by hand.
4
+ #
5
+ # A {PGN::Game} has an interactive {#play} method, and can also return
6
+ # a list of positions in {PGN::Position} format or FEN.
7
+ #
8
+ # @!attribute tags
9
+ # @return [Hash<String, String>] metadata about the game
10
+ # @example
11
+ # game.tags #=> {"White" => "Kasparov", "Black" => "Deep Blue"}
12
+ #
13
+ # @!attribute moves
14
+ # @return [Array<String>] a list of the moves in standard algebraic
15
+ # notation
16
+ # @example
17
+ # game.moves #=> ["e4", "c5", "Nf3", "d6", "d4", "cxd4"]
18
+ #
19
+ # @!attribute result
20
+ # @return [String] the outcome of the game
21
+ # @example
22
+ # game.result #=> "1-0"
23
+ #
24
+ class Game
25
+ attr_accessor :tags, :moves, :result
26
+
27
+ LEFT = "a"
28
+ RIGHT = "d"
29
+ EXIT = "\u{0003}"
30
+
31
+ # @param moves [Array<String>] a list of moves in SAN
32
+ # @param tags [Hash<String, String>] metadata about the game
33
+ # @param result [String] the outcome of the game
34
+ #
35
+ def initialize(moves, tags = nil, result = nil)
36
+ self.moves = moves
37
+ self.tags = tags
38
+ self.result = result
39
+ end
40
+
41
+ # @return [Array<PGN::Position>] list of the {PGN::Position}s in the game
42
+ #
43
+ def positions
44
+ @positions ||= begin
45
+ position = PGN::Position.start
46
+ arr = [position]
47
+ self.moves.each do |move|
48
+ new_pos = position.move(move)
49
+ arr << new_pos
50
+ position = new_pos
51
+ end
52
+ arr
53
+ end
54
+ end
55
+
56
+ # @return [Array<String>] list of the fen representations of the positions
57
+ #
58
+ def fen_list
59
+ self.positions.map {|p| p.to_fen.inspect }
60
+ end
61
+
62
+ # Interactively step through the game
63
+ #
64
+ # Use +d+ to move forward, +a+ to move backward, and +^C+ to exit.
65
+ #
66
+ def play
67
+ index = 0
68
+ loop do
69
+ puts "\e[H\e[2J"
70
+ puts self.positions[index].inspect
71
+ case STDIN.getch
72
+ when LEFT
73
+ index -= 1 if index > 0
74
+ when RIGHT
75
+ index += 1 if index < self.moves.length
76
+ when EXIT
77
+ break
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
data/lib/pgn/move.rb ADDED
@@ -0,0 +1,167 @@
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
+ class Move
59
+ attr_accessor :san, :player
60
+ attr_accessor :piece, :destination, :promotion, :check, :capture, :disambiguation, :castle
61
+
62
+ # A regular expression for matching moves in standard algebraic
63
+ # notation
64
+ #
65
+ SAN_REGEX = %r{
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
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
+
97
+ match.names.each do |name|
98
+ if self.respond_to?(name)
99
+ self.send("#{name}=", match[name])
100
+ end
101
+ end
102
+ end
103
+
104
+ def piece=(val)
105
+ return if san.match("O-O")
106
+
107
+ val ||= "P"
108
+ @piece = self.black? ?
109
+ val.downcase :
110
+ val
111
+ end
112
+
113
+ def promotion=(val)
114
+ if val
115
+ val.downcase! if self.black?
116
+ @promotion = val.delete("=")
117
+ end
118
+ end
119
+
120
+ def capture=(val)
121
+ @capture = !!val
122
+ end
123
+
124
+ def disambiguation=(val)
125
+ @disambiguation = (val == "" ? nil : val)
126
+ end
127
+
128
+ def castle=(val)
129
+ if val
130
+ @castle = "K" if val == "O-O"
131
+ @castle = "Q" if val == "O-O-O"
132
+ @castle.downcase! if self.black?
133
+ end
134
+ end
135
+
136
+ # @return [Boolean] whether the move results in check
137
+ #
138
+ def check?
139
+ self.check == "+"
140
+ end
141
+
142
+ # @return [Boolean] whether the move results in checkmate
143
+ #
144
+ def checkmate?
145
+ self.check == "#"
146
+ end
147
+
148
+ # @return [Boolean] whether it's white's turn
149
+ #
150
+ def white?
151
+ self.player == :white
152
+ end
153
+
154
+ # @return [Boolean] whether it's black's turn
155
+ #
156
+ def black?
157
+ self.player == :black
158
+ end
159
+
160
+ # @return [Boolean] whether the piece being moved is a pawn
161
+ #
162
+ def pawn?
163
+ ['P', 'p'].include?(self.piece)
164
+ end
165
+
166
+ end
167
+ end
@@ -0,0 +1,339 @@
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
+ }
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
+ }
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
+ }
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
+ }
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
+ end
94
+
95
+ # @return [PGN::Board] the board after the move is made
96
+ #
97
+ def result_board
98
+ compute_origin
99
+
100
+ new_board = self.board.dup
101
+ new_board.change!(changes)
102
+
103
+ new_board
104
+ end
105
+
106
+ # @return [Array<String>] which castling moves are no longer available
107
+ #
108
+ def castling_restrictions
109
+ compute_origin
110
+
111
+ restrict = case self.move.piece
112
+ when "K" then "KQ"
113
+ when "k" then "kq"
114
+ when "R"
115
+ {"a1" => "Q", "h1" => "K"}[self.origin]
116
+ when "r"
117
+ {"a8" => "q", "h8" => "k"}[self.origin]
118
+ end
119
+
120
+ restrict = "KQ" if ['K', 'Q'].include? move.castle
121
+ restrict = "kq" if ['k', 'q'].include? move.castle
122
+
123
+ restrict ||= ''
124
+
125
+ restrict.split('')
126
+ end
127
+
128
+ # @return [Boolean] whether to increment the halfmove clock
129
+ #
130
+ def increment_halfmove?
131
+ !(self.move.capture || self.move.pawn?)
132
+ end
133
+
134
+ # @return [Boolean] whether to increment the fullmove counter
135
+ #
136
+ def increment_fullmove?
137
+ self.move.black?
138
+ end
139
+
140
+ # @return [String, nil] the en passant square if applicable
141
+ #
142
+ def en_passant_square
143
+ compute_origin
144
+
145
+ return nil if move.castle
146
+
147
+ if self.move.pawn? && (self.origin[1].to_i - self.move.destination[1].to_i).abs == 2
148
+ self.move.white? ?
149
+ self.origin[0] + '3' :
150
+ self.origin[0] + '6'
151
+ end
152
+ end
153
+
154
+ private
155
+
156
+ def changes
157
+ compute_origin
158
+
159
+ changes = {}
160
+ changes.merge!(CASTLING[self.move.castle]) if self.move.castle
161
+ changes.merge!(
162
+ self.origin => nil,
163
+ self.move.destination => self.move.piece,
164
+ en_passant_capture => nil,
165
+ )
166
+ if self.move.promotion
167
+ changes[self.move.destination] = self.move.promotion
168
+ end
169
+
170
+ changes.reject! {|key, _| key.nil? }
171
+
172
+ changes
173
+ end
174
+
175
+ # Using the current position and move, figure out where the piece
176
+ # came from.
177
+ #
178
+ def compute_origin
179
+ return nil if move.castle
180
+
181
+ @origin ||= begin
182
+ possibilities = case move.piece
183
+ when /[brq]/i then direction_origins
184
+ when /[kn]/i then move_origins
185
+ when /p/i then pawn_origins
186
+ end
187
+
188
+ if possibilities.length > 1
189
+ possibilities = disambiguate(possibilities)
190
+ end
191
+
192
+ self.board.position_for(possibilities.first)
193
+ end
194
+ end
195
+
196
+ # From the destination square, move in each direction stopping if we
197
+ # reach the end of the board. If we encounter a piece, add it to the
198
+ # list of origin possibilities if it is the moving piece, or else
199
+ # check the next direction.
200
+ #
201
+ def direction_origins
202
+ directions = DIRECTIONS[move.piece.downcase]
203
+ possibilities = []
204
+
205
+ directions.each do |dir|
206
+ piece, square = first_piece(destination_coords, dir)
207
+ possibilities << square if piece == self.move.piece
208
+ end
209
+
210
+ possibilities
211
+ end
212
+
213
+ # From the destination square, make each move. If it is a valid
214
+ # square and matches the moving piece, add it to the list of origin
215
+ # possibilities.
216
+ #
217
+ def move_origins(moves = nil)
218
+ moves ||= MOVES[move.piece.downcase]
219
+ possibilities = []
220
+ file, rank = destination_coords
221
+
222
+ moves.each do |i, j|
223
+ f = file + i
224
+ r = rank + j
225
+
226
+ if valid_square?(f, r) && self.board.at(f, r) == move.piece
227
+ possibilities << [f, r]
228
+ end
229
+ end
230
+
231
+ possibilities
232
+ end
233
+
234
+ # Computes the possbile pawn origins based on the destination square
235
+ # and whether or not the move is a capture.
236
+ #
237
+ def pawn_origins
238
+ _, rank = destination_coords
239
+ double_rank = (rank == 3 && self.move.white?) || (rank == 4 && self.move.black?)
240
+
241
+ pawn_moves = PAWN_MOVES[self.move.piece]
242
+
243
+ moves = self.move.capture ? pawn_moves[:capture] : pawn_moves[:normal]
244
+ moves += pawn_moves[:double] if double_rank
245
+
246
+ move_origins(moves)
247
+ end
248
+
249
+ def disambiguate(possibilities)
250
+ possibilities = disambiguate_san(possibilities)
251
+ possibilities = disambiguate_pawns(possibilities) if possibilities.length > 1
252
+ possibilities = disambiguate_discovered_check(possibilities) if possibilities.length > 1
253
+
254
+ possibilities
255
+ end
256
+
257
+ # Try to disambiguate based on the standard algebraic notation.
258
+ #
259
+ def disambiguate_san(possibilities)
260
+ move.disambiguation ?
261
+ possibilities.select {|p| self.board.position_for(p).match(move.disambiguation) } :
262
+ possibilities
263
+ end
264
+
265
+ # A pawn can't move two spaces if there is a pawn in front of it.
266
+ #
267
+ def disambiguate_pawns(possibilities)
268
+ self.move.piece.match(/p/i) && !self.move.capture ?
269
+ possibilities.reject {|p| self.board.position_for(p).match(/2|7/) } :
270
+ possibilities
271
+ end
272
+
273
+ # A piece can't move if it would result in a discovered check.
274
+ #
275
+ def disambiguate_discovered_check(possibilities)
276
+ DIRECTIONS.each do |attacking_piece, directions|
277
+ attacking_piece = attacking_piece.upcase if self.move.black?
278
+
279
+ directions.each do |dir|
280
+ piece, square = first_piece(king_position, dir)
281
+ next unless piece == self.move.piece && possibilities.include?(square)
282
+
283
+ piece, _ = first_piece(square, dir)
284
+ possibilities.reject! {|p| p == square } if piece == attacking_piece
285
+ end
286
+ end
287
+
288
+ possibilities
289
+ end
290
+
291
+ def first_piece(from, direction)
292
+ file, rank = from
293
+ i, j = direction
294
+
295
+ piece = nil
296
+
297
+ while valid_square?(file += i, rank += j)
298
+ break if piece = self.board.at(file, rank)
299
+ end
300
+
301
+ [piece, [file, rank]]
302
+ end
303
+
304
+ # If the move is a capture and there is no piece on the
305
+ # destination square, it must be an en passant capture.
306
+ #
307
+ def en_passant_capture
308
+ return nil if self.move.castle
309
+
310
+ if !self.board.at(self.move.destination) && self.move.capture
311
+ self.move.destination[0] + self.origin[1]
312
+ end
313
+ end
314
+
315
+ def king_position
316
+ king = self.move.white? ? 'K' : 'k'
317
+
318
+ coords = nil
319
+ 0.upto(7) do |file|
320
+ 0.upto(7) do |rank|
321
+ if self.board.at(file, rank) == king
322
+ coords = [file, rank]
323
+ end
324
+ end
325
+ end
326
+
327
+ coords
328
+ end
329
+
330
+ def valid_square?(file, rank)
331
+ (0..7) === file && (0..7) === rank
332
+ end
333
+
334
+ def destination_coords
335
+ self.board.coordinates_for(self.move.destination)
336
+ end
337
+
338
+ end
339
+ end