chess_data 1.0.6

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.
@@ -0,0 +1,190 @@
1
+ module ChessData
2
+
3
+ # Represents a chess game, as read in from a PGN file.
4
+ #
5
+ # Header information in a pgn is stored in a hash table, and method_missing
6
+ # used to provide an accessor-like mechanism for storing/retrieving header
7
+ # information. For example:
8
+ #
9
+ # db = Database.new
10
+ # db.add_games_from "test/data/fischer.pgn"
11
+ # game = db[0]
12
+ # puts game.event
13
+ # puts game.white
14
+ # puts game.black
15
+ #
16
+ # Each of the 'event', 'white', 'black' values are retrieved from the
17
+ # PGN header.
18
+ #
19
+ # New key values can be created and assigned to, to construct a header for
20
+ # a game. For example:
21
+ #
22
+ # db = Database.new
23
+ # game = Game.new
24
+ # game.white = "Peter"
25
+ # game.black = "Paul"
26
+ # game.result = "1-0"
27
+ # game << "e4"
28
+ # db << game
29
+ # db.to_file "mygames.pgn"
30
+ #
31
+ # And the pgn file will contain:
32
+ #
33
+ # [Result "1-0"]
34
+ # [White "Peter"]
35
+ # [Black "Paul"]
36
+ #
37
+ # 1. e4 1-0
38
+ #
39
+ #
40
+ class Game
41
+ # Stores the sequence of moves in the game.
42
+ attr_accessor :moves
43
+
44
+ def initialize
45
+ @header = {}
46
+ @moves = []
47
+ end
48
+
49
+ # method_missing is used for accessing key-value terms in the header.
50
+ # * Any unknown method call is checked if it is the key of a header
51
+ # item and, if so, the value for that key is returned.
52
+ # * If the unknown method has an '=' sign in it, a new key is
53
+ # created and assigned a value, which must be an argument to method.
54
+ def method_missing iname, *args
55
+ name = iname.to_s
56
+ if @header.has_key? name
57
+ @header[name]
58
+ elsif name.include? "=" # assign, so create new key
59
+ key = name.delete "="
60
+ @header[key] = args[0]
61
+ else
62
+ puts "Unknown key '#{name}' for header #{@header}"
63
+ super
64
+ end
65
+ end
66
+
67
+ # Append given _move_ to list of moves.
68
+ # Given move can be a valid move type or a string.
69
+ #
70
+ # An InvalidMoveError is raised if the move is not valid.
71
+ #
72
+ def << move
73
+ if move.respond_to? :make_move
74
+ @moves << move
75
+ elsif move.kind_of? String
76
+ @moves << Moves.new_move(move)
77
+ else
78
+ raise InvalidMoveError.new("Invalid type of move: #{move}")
79
+ end
80
+ end
81
+
82
+ # Return the number of half-moves in the game.
83
+ def half_moves
84
+ @moves.size
85
+ end
86
+
87
+ # Return the start position for the game.
88
+ # PGN games may provide a start-position, if they do not begin from the start position.
89
+ def start_position
90
+ if @header.has_key? "fen"
91
+ ChessData::Board.from_fen @header["fen"]
92
+ else
93
+ ChessData::Board.start_position
94
+ end
95
+ end
96
+
97
+ # Step through the game from start position, one half-move at a time.
98
+ # Yields to a block the current board position and the next move.
99
+ # Yields final board position and result at end of game.
100
+ def play_game
101
+ board = start_position
102
+ @moves.each do |move|
103
+ yield board, move
104
+ board = move.make_move board
105
+ end
106
+ yield board, result
107
+ end
108
+
109
+ # Test if game meets the position definition given in the block
110
+ # using Game#play_game to step through the game.
111
+ def search &block
112
+ defn = ChessData::PositionDefinition.new(&block)
113
+ play_game do |board|
114
+ return true if defn.check board
115
+ end
116
+ return false
117
+ end
118
+
119
+ # Write game in PGN format to given IO stream.
120
+ # This method is usually called from Database#to_file
121
+ # but can also be called directly.
122
+ #
123
+ def to_pgn stream
124
+ @header.keys.each do |key|
125
+ stream.puts "[#{key.capitalize} \"#{@header[key]}\"]"
126
+ end
127
+ stream.puts # blank separating line
128
+ move_str = ""
129
+ move_number = 1
130
+ @moves.each_slice(2) do |full_move|
131
+ move_str += "#{move_number}. #{full_move[0]} #{full_move[1]} "
132
+ move_number += 1
133
+ end
134
+ move_str += result
135
+ stream.puts WordWrap.ww(move_str, 80)
136
+ end
137
+
138
+ # Regular expression used to match a PGN header line.
139
+ MatchHeader = /\[(\w+) \"(.*)\"\]/
140
+
141
+ # Reads a single game from a given IO stream.
142
+ # Returns nil if failed to read a game or its moves.
143
+ def Game.from_pgn stream
144
+ game = Game.new
145
+ moves = []
146
+ # ignore blank lines
147
+ begin
148
+ line = stream.gets
149
+ return nil if line.nil? # failed to read game/empty file
150
+ end while line.strip.empty?
151
+ # read the header
152
+ while MatchHeader =~ line
153
+ game.send "#{$1.downcase}=", $2
154
+ line = stream.gets.strip
155
+ end
156
+ # ignore blank lines
157
+ begin
158
+ line = stream.gets
159
+ return nil if line.nil? # failed to read moves for game
160
+ end while line.strip.empty?
161
+ # read the moves
162
+ begin
163
+ next if line.start_with? "%"
164
+ semi_index = line.index ";" # look for ; comment start
165
+ line = line[0...semi_index] if semi_index # and strip it
166
+ moves << line.strip
167
+ line = stream.gets
168
+ end until line.nil? || line.strip.empty? || MatchHeader =~ line
169
+ # return an event line if it immediately follows the moves
170
+ # so can be read for next game
171
+ stream.ungetc line if MatchHeader =~ line
172
+ # parse the moves and add to game
173
+ move_str = moves.join(" ")
174
+ if /{.*}/.match(move_str) # remove { } comments
175
+ move_str = $` + " " + $'
176
+ end
177
+ move_str.split(" ").each do |token|
178
+ case token
179
+ when "1-0", "0-1", "1/2-1/2", "*" then game.result = token
180
+ when /^\d+/ then ; # ignore the move number
181
+ when /^$\d+/ then ; # ignore NAG
182
+ when Moves::LegalMove then game << Moves.new_move(token)
183
+ end
184
+ end
185
+
186
+ return game
187
+ end
188
+ end
189
+ end
190
+
@@ -0,0 +1,512 @@
1
+
2
+ module ChessData
3
+
4
+ # Used to indicate an error in making a move.
5
+ class InvalidMoveError < RuntimeError
6
+ end
7
+
8
+ # Moves is a collection of regular expressions and methods to recognise
9
+ # how moves are written in PGN files, and to make the moves on a given
10
+ # board.
11
+ #
12
+ # As the moves are obtained from PGN files, they are assumed to be correct.
13
+ #
14
+ module Moves
15
+
16
+ # :nodoc:
17
+ # Regular expressions to match each of the move types.
18
+ Square = /[a-h][1-8]/
19
+ Piece = /[KQRBN]/
20
+ MatchKingsideCastles = /^O-O\+?\Z/
21
+ MatchQueensideCastles = /^O-O-O\+?\Z/
22
+ MatchPieceMove = /^(#{Piece})([a-h]?|[1-8]?)x?(#{Square})\+?\Z/
23
+ MatchPawnCapture = /^([a-h])x(#{Square})\+?\Z/
24
+ MatchPromotionPawnMove = /^([a-h][18])=([QqRrBbNn])\+?\Z/
25
+ MatchSimplePawnMove = /^(#{Square})\+?\Z/
26
+ MatchPromotionPawnCapture = /^([a-h])x([a-h][18])=([QqRrBbNn])\+?\Z/
27
+
28
+ # Combined regular expression, to match a legal move.
29
+ LegalMove = /#{MatchKingsideCastles}|#{MatchQueensideCastles}|#{MatchPieceMove}|#{MatchPawnCapture}|#{MatchPromotionPawnMove}|#{MatchSimplePawnMove}|#{MatchPromotionPawnCapture}/
30
+ # :doc:
31
+
32
+ # Returns an instance of the appropriate move type.
33
+ # string:: a move read from a PGN file.
34
+ def Moves.new_move string
35
+ case string
36
+ when MatchKingsideCastles then KingsideCastles.new
37
+ when MatchQueensideCastles then QueensideCastles.new
38
+ when MatchPieceMove then PieceMove.new string
39
+ when MatchPawnCapture then PawnCapture.new string
40
+ when MatchPromotionPawnMove then PromotionPawnMove.new string
41
+ when MatchSimplePawnMove then SimplePawnMove.new string
42
+ when MatchPromotionPawnCapture then PromotionPawnCapture.new string
43
+ else raise InvalidMoveError.new("Invalid move: #{string}")
44
+ end
45
+ end
46
+
47
+ # Return true if given _piece_ can move from _start_ square to _finish_ on given _board_.
48
+ def Moves.can_reach board, piece, start, finish
49
+ start = start.upcase
50
+ finish = finish.upcase
51
+ case piece
52
+ when "K", "k" then Moves.king_can_reach start, finish
53
+ when "Q", "q" then Moves.queen_can_reach board, start, finish
54
+ when "R", "r" then Moves.rook_can_reach board, start, finish
55
+ when "B", "b" then Moves.bishop_can_reach board, start,finish
56
+ when "N", "n" then Moves.knight_can_reach start, finish
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ # Return true if moving the giving piece from start to finish
63
+ # will leave the moving side's king in check.
64
+ def Moves.king_left_in_check board, piece, start, finish
65
+ test_board = board.clone
66
+ test_board[start] = nil
67
+ test_board[finish] = piece
68
+
69
+ if board.to_move == "w"
70
+ test_board.white_king_in_check?
71
+ else
72
+ test_board.black_king_in_check?
73
+ end
74
+ end
75
+
76
+ def Moves.king_can_reach start, finish
77
+ Moves.step_h(start, finish) <= 1 && Moves.step_v(start, finish) <= 1
78
+ end
79
+
80
+ def Moves.queen_can_reach board, start,finish
81
+ Moves.rook_can_reach(board, start, finish) ||
82
+ Moves.bishop_can_reach(board, start, finish)
83
+ end
84
+
85
+ def Moves.rook_can_reach board, start, finish
86
+ start_col, start_row = Board.square_to_coords start
87
+ end_col, end_row = Board.square_to_coords finish
88
+ if start_col == end_col # moving along column
89
+ row_1 = [start_row, end_row].min + 1
90
+ row_2 = [start_row, end_row].max - 1
91
+ row_1.upto(row_2) do |row|
92
+ return false unless board[Board.coords_to_square(start_col, row)] == nil
93
+ end
94
+ elsif start_row == end_row # moving along row
95
+ col_1 = [start_col, end_col].min + 1
96
+ col_2 = [start_col, end_col].max - 1
97
+ col_1.upto(col_2) do |col|
98
+ return false unless board[Board.coords_to_square(col, start_row)] == nil
99
+ end
100
+ else
101
+ return false
102
+ end
103
+ return true
104
+ end
105
+
106
+ def Moves.bishop_can_reach board, start,finish
107
+ return false unless Moves.step_h(start,finish) == Moves.step_v(start, finish)
108
+ start_col, start_row = Board.square_to_coords start
109
+ end_col, end_row = Board.square_to_coords finish
110
+ dirn_h = (end_row - start_row) / (end_row - start_row).abs
111
+ dirn_v = (end_col - start_col) / (end_col - start_col).abs
112
+ 1.upto(Moves.step_h(start,finish)-1) do |i|
113
+ square = Board.coords_to_square(start_col+(i*dirn_v),
114
+ start_row+(i*dirn_h))
115
+ unless board[square] == nil
116
+ return false
117
+ end
118
+ end
119
+ return true
120
+ end
121
+
122
+ def Moves.knight_can_reach start, finish
123
+ h = Moves.step_h start, finish
124
+ v = Moves.step_v start, finish
125
+ return (h == 2 && v == 1) || (h == 1 && v == 2)
126
+ end
127
+
128
+ # Return size of horizontal gap between start and finish
129
+ def Moves.step_h start, finish
130
+ (start.bytes[0] - finish.bytes[0]).abs
131
+ end
132
+
133
+ # Return size of vertical gap between start and finish
134
+ def Moves.step_v start, finish
135
+ (start.bytes[1] - finish.bytes[1]).abs
136
+ end
137
+
138
+ # Methods to support king-side castling move.
139
+ class KingsideCastles
140
+ def to_s
141
+ "O-O"
142
+ end
143
+
144
+ # Depending on the colour to move, will either castle king-side for white or black.
145
+ # Returns a new instance of the board.
146
+ def make_move board
147
+ if board.to_move == "w"
148
+ white_castles board
149
+ else
150
+ black_castles board
151
+ end
152
+ end
153
+
154
+ private
155
+ def white_castles board
156
+ raise InvalidMoveError.new("white O-O") unless board["E1"] == "K" &&
157
+ board["F1"] == nil && board["G1"] == nil &&
158
+ board["H1"] == "R" && board.white_king_side_castling
159
+
160
+ revised_board = board.clone
161
+ revised_board["E1"] = nil
162
+ revised_board["F1"] = "R"
163
+ revised_board["G1"] = "K"
164
+ revised_board["H1"] = nil
165
+
166
+ revised_board.to_move = "b"
167
+ revised_board.enpassant_target = "-"
168
+ revised_board.halfmove_clock += 1
169
+ revised_board.white_king_side_castling = false
170
+ revised_board.white_queen_side_castling = false
171
+
172
+ return revised_board
173
+ end
174
+
175
+ def black_castles board
176
+ raise InvalidMoveError.new("black O-O") unless board["E8"] == "k" &&
177
+ board["F8"] == nil && board["G8"] == nil &&
178
+ board["H8"] == "r" && board.black_king_side_castling
179
+
180
+ revised_board = board.clone
181
+ revised_board["E8"] = nil
182
+ revised_board["F8"] = "r"
183
+ revised_board["G8"] = "k"
184
+ revised_board["H8"] = nil
185
+
186
+ revised_board.to_move = "w"
187
+ revised_board.enpassant_target = "-"
188
+ revised_board.halfmove_clock += 1
189
+ revised_board.fullmove_number += 1
190
+ revised_board.black_king_side_castling = false
191
+ revised_board.black_queen_side_castling = false
192
+
193
+ return revised_board
194
+ end
195
+ end
196
+
197
+ # Methods to support queen-side castling move.
198
+ class QueensideCastles
199
+ def to_s
200
+ "O-O-O"
201
+ end
202
+
203
+ # Depending on the colour to move, will either castle queen-side for white or black.
204
+ # Returns a new instance of the board.
205
+ def make_move board
206
+ if board.to_move == "w"
207
+ white_castles board
208
+ else
209
+ black_castles board
210
+ end
211
+ end
212
+
213
+ private
214
+ def white_castles board
215
+ raise InvalidMoveError.new("white O-O-O") unless board["E1"] == "K" &&
216
+ board["D1"] == nil && board["C1"] == nil &&
217
+ board["B1"] == nil && board["A1"] == "R" &&
218
+ board.white_queen_side_castling
219
+
220
+ revised_board = board.clone
221
+ revised_board["E1"] = nil
222
+ revised_board["D1"] = "R"
223
+ revised_board["C1"] = "K"
224
+ revised_board["B1"] = nil
225
+ revised_board["A1"] = nil
226
+
227
+ revised_board.to_move = "b"
228
+ revised_board.enpassant_target = "-"
229
+ revised_board.halfmove_clock += 1
230
+ revised_board.white_king_side_castling = false
231
+ revised_board.white_queen_side_castling = false
232
+
233
+ return revised_board
234
+ end
235
+
236
+ def black_castles board
237
+ raise InvalidMoveError.new("black O-O") unless board["E8"] == "k" &&
238
+ board["D8"] == nil && board["C8"] == nil &&
239
+ board["B8"] == nil && board["A8"] == "r" &&
240
+ board.black_queen_side_castling
241
+
242
+ revised_board = board.clone
243
+ revised_board["E8"] = nil
244
+ revised_board["D8"] = "r"
245
+ revised_board["C8"] = "k"
246
+ revised_board["B8"] = nil
247
+ revised_board["A8"] = nil
248
+
249
+ revised_board.to_move = "w"
250
+ revised_board.enpassant_target = "-"
251
+ revised_board.halfmove_clock += 1
252
+ revised_board.fullmove_number += 1
253
+ revised_board.black_king_side_castling = false
254
+ revised_board.black_queen_side_castling = false
255
+
256
+ return revised_board
257
+ end
258
+ end
259
+
260
+ # Methods to support a simple pawn move, moving directly forward.
261
+ class SimplePawnMove
262
+ def initialize move
263
+ @move_string = move
264
+ move =~ MatchSimplePawnMove
265
+ @destination = $1
266
+ end
267
+
268
+ def to_s
269
+ @move_string
270
+ end
271
+
272
+ # Returns a new instance of the board after move is made.
273
+ def make_move board
274
+ if board.to_move == "w"
275
+ white_move board
276
+ else
277
+ black_move board
278
+ end
279
+ end
280
+
281
+ private
282
+ def white_move board
283
+ revised_board = board.clone
284
+
285
+ if single_step board
286
+ revised_board[@destination] = "P"
287
+ revised_board[previous_square(board.to_move)] = nil
288
+ revised_board.enpassant_target = "-"
289
+ elsif initial_step board
290
+ revised_board[@destination] = "P"
291
+ revised_board[initial_square(board.to_move)] = nil
292
+ revised_board.enpassant_target = previous_square(board.to_move)
293
+ else
294
+ raise InvalidMoveError.new "white #{@move_string}"
295
+ end
296
+
297
+ revised_board.to_move = "b"
298
+ revised_board.halfmove_clock = 0
299
+
300
+ return revised_board
301
+ end
302
+
303
+ def black_move board
304
+ revised_board = board.clone
305
+
306
+ if single_step board
307
+ revised_board[@destination] = "p"
308
+ revised_board[previous_square(board.to_move)] = nil
309
+ revised_board.enpassant_target = "-"
310
+ elsif initial_step board
311
+ revised_board[@destination] = "p"
312
+ revised_board[initial_square(board.to_move)] = nil
313
+ revised_board.enpassant_target = previous_square(board.to_move)
314
+ else
315
+ raise InvalidMoveError.new "black #{@move_string}"
316
+ end
317
+
318
+ revised_board.to_move = "w"
319
+ revised_board.halfmove_clock = 0
320
+ revised_board.fullmove_number += 1
321
+
322
+ return revised_board
323
+ end
324
+
325
+ def single_step board
326
+ if board.to_move == "w"
327
+ pawn = "P"
328
+ else
329
+ pawn = "p"
330
+ end
331
+ board[@destination] == nil &&
332
+ board[previous_square(board.to_move)] == pawn
333
+ end
334
+
335
+ def initial_step board
336
+ if board.to_move == "w"
337
+ pawn = "P"
338
+ rank = 4
339
+ else
340
+ pawn = "p"
341
+ rank = 5
342
+ end
343
+ board[@destination] == nil && @destination[1].to_i == rank &&
344
+ board[initial_square(board.to_move)] == pawn
345
+ end
346
+
347
+ def previous_square colour
348
+ if colour == "w"
349
+ offset = -1
350
+ else
351
+ offset = +1
352
+ end
353
+ "#{@destination[0]}#{@destination[1].to_i+offset}"
354
+ end
355
+
356
+ def initial_square colour
357
+ if colour == "w"
358
+ initial_rank = 2
359
+ else
360
+ initial_rank = 7
361
+ end
362
+ "#{@destination[0]}#{initial_rank}"
363
+ end
364
+ end
365
+
366
+ # Methods to support a pawn move leading to promotion.
367
+ class PromotionPawnMove < SimplePawnMove
368
+ def initialize string
369
+ @move_string = string
370
+ string =~ MatchPromotionPawnMove
371
+ string.split("=")
372
+ @destination = $1
373
+ @piece = $2
374
+ end
375
+
376
+ # Returns a new instance of the board after move is made.
377
+ def make_move board
378
+ @piece.downcase! if board.to_move == "b"
379
+ revised_board = super board
380
+ revised_board[@destination] = @piece
381
+ return revised_board
382
+ end
383
+ end
384
+
385
+ # Methods to support a pawn move which makes a capture.
386
+ class PawnCapture
387
+ def initialize move
388
+ @move_string = move
389
+ move =~ MatchPawnCapture
390
+ @source = $1
391
+ @destination = $2
392
+ end
393
+
394
+ def to_s
395
+ @move_string
396
+ end
397
+
398
+ # Returns a new instance of the board after move is made.
399
+ def make_move board
400
+ origin = find_origin board.to_move
401
+
402
+ revised_board = board.clone
403
+ if @destination == board.enpassant_target
404
+ revised_board["#{@destination[0]}#{origin[1]}"] = nil
405
+ end
406
+ revised_board[origin] = nil
407
+ revised_board[@destination] = board[origin]
408
+ revised_board.enpassant_target = "-"
409
+ revised_board.halfmove_clock = 0
410
+ if board.to_move == "w"
411
+ revised_board.to_move = "b"
412
+ else
413
+ revised_board.to_move = "w"
414
+ revised_board.fullmove_number += 1
415
+ end
416
+
417
+ return revised_board
418
+ end
419
+
420
+ private
421
+ # For a pawn capture, find the originating row and create origin square
422
+ def find_origin to_move
423
+ row = @destination[1].to_i
424
+ if to_move == "w"
425
+ row -= 1
426
+ else
427
+ row += 1
428
+ end
429
+
430
+ return "#{@source}#{row}"
431
+ end
432
+ end
433
+
434
+ # Methods to support a pawn move which is both a capture and a promotion.
435
+ class PromotionPawnCapture < PawnCapture
436
+ def initialize move
437
+ @move_string = move
438
+ move =~ MatchPromotionPawnCapture
439
+ @source = $1
440
+ @destination = $2
441
+ @piece = $3
442
+ end
443
+
444
+ def to_s
445
+ @move_string
446
+ end
447
+
448
+ # Returns a new instance of the board after move is made.
449
+ def make_move board
450
+ @piece.downcase! if board.to_move == "b"
451
+ revised_board = super board
452
+ revised_board[@destination] = @piece
453
+ return revised_board
454
+ end
455
+ end
456
+
457
+ # Methods to support a piece move.
458
+ class PieceMove
459
+ def initialize move
460
+ @move_string = move
461
+ move =~ MatchPieceMove
462
+ @piece = $1
463
+ @identifier = $2
464
+ @destination = $3
465
+ @is_capture = move.include? "x"
466
+ end
467
+
468
+ def to_s
469
+ @move_string
470
+ end
471
+
472
+ # Returns a new instance of the board after move is made.
473
+ def make_move board
474
+ @piece.downcase! if board.to_move == "b"
475
+ # for given piece type, locate those pieces on board which can reach destination
476
+ origin = board.locations_of(@piece, @identifier).select do |loc|
477
+ Moves.can_reach(board, @piece, loc, @destination)
478
+ end
479
+ # filter out ambiguities raised by king being left in check
480
+ if origin.length > 1
481
+ origin = origin.delete_if do |loc|
482
+ Moves.king_left_in_check(board, @piece, loc, @destination)
483
+ end
484
+ end
485
+ # there should only be one unique piece at this point
486
+ # raise an InvalidMoveError if not
487
+ unless origin.length == 1 && board[origin.first] == @piece
488
+ raise InvalidMoveError, "Not a unique/valid choice for #{@piece} to #{@destination}"
489
+ end
490
+ # setup a revised board with the move completed
491
+ revised_board = board.clone
492
+ revised_board[origin.first] = nil
493
+ revised_board[@destination] = @piece
494
+ revised_board.to_move = case board.to_move
495
+ when "w" then "b"
496
+ when "b" then "w"
497
+ end
498
+ revised_board.enpassant_target = "-"
499
+ if @is_capture
500
+ revised_board.halfmove_clock = 0
501
+ else
502
+ revised_board.halfmove_clock += 1
503
+ end
504
+ revised_board.fullmove_number += 1 if board.to_move == "b"
505
+
506
+ return revised_board
507
+ end
508
+ end
509
+ end
510
+
511
+ end
512
+