pgn 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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