pgn 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.rspec +2 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/Rakefile +1 -0
- data/TODO.md +4 -0
- data/examples/immortal_game.pgn +17 -0
- data/lib/pgn.rb +22 -0
- data/lib/pgn/board.rb +183 -0
- data/lib/pgn/fen.rb +146 -0
- data/lib/pgn/game.rb +82 -0
- data/lib/pgn/move.rb +167 -0
- data/lib/pgn/move_calculator.rb +339 -0
- data/lib/pgn/parser.rb +119 -0
- data/lib/pgn/position.rb +129 -0
- data/lib/pgn/version.rb +3 -0
- data/pgn.gemspec +26 -0
- data/spec/fen_spec.rb +87 -0
- data/spec/game_spec.rb +13 -0
- data/spec/parser_spec.rb +14 -0
- data/spec/position_spec.rb +44 -0
- data/spec/spec_helper.rb +19 -0
- metadata +129 -0
data/lib/pgn/parser.rb
ADDED
@@ -0,0 +1,119 @@
|
|
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
|
+
rule(:wsp => /\s+/).skip!
|
9
|
+
|
10
|
+
rule("[")
|
11
|
+
rule("]")
|
12
|
+
rule("(")
|
13
|
+
rule(")")
|
14
|
+
|
15
|
+
start(:pgn_database)
|
16
|
+
|
17
|
+
rule(:pgn_database) do |r|
|
18
|
+
r[].as { [] }
|
19
|
+
r[:pgn_game, :pgn_database].as {|game, database| database << game }
|
20
|
+
end
|
21
|
+
|
22
|
+
rule(:pgn_game) do |r|
|
23
|
+
r[:tag_section, :movetext_section].as {|tags, moves| {tags: tags, result: moves.pop, moves: moves} }
|
24
|
+
end
|
25
|
+
|
26
|
+
rule(:tag_section) do |r|
|
27
|
+
r[:tag_pair, :tag_section].as {|pair, section| section.merge(pair) }
|
28
|
+
r[:tag_pair]
|
29
|
+
end
|
30
|
+
|
31
|
+
rule(:tag_pair) do |r|
|
32
|
+
r["[", :tag_name, :tag_value, "]"].as {|_, a, b, _| {a => b} }
|
33
|
+
end
|
34
|
+
|
35
|
+
rule(:tag_value) do |r|
|
36
|
+
r[:string].as {|value| value[1...-1] }
|
37
|
+
end
|
38
|
+
|
39
|
+
rule(:movetext_section) do |r|
|
40
|
+
r[:element_sequence, :game_termination].as {|a, b| a.reverse << b }
|
41
|
+
end
|
42
|
+
|
43
|
+
rule(:element_sequence) do |r|
|
44
|
+
r[:element, :element_sequence].as {|element, sequence| element.nil? ? sequence : sequence << element }
|
45
|
+
r[].as { [] }
|
46
|
+
#r[:recursive_variation, :element_sequence]
|
47
|
+
#r[:recursive_variation]
|
48
|
+
end
|
49
|
+
|
50
|
+
rule(:element) do |r|
|
51
|
+
r[:move_number_indication].as { nil }
|
52
|
+
r[:san_move]
|
53
|
+
#r[:numeric_annotation_glyph]
|
54
|
+
end
|
55
|
+
|
56
|
+
#rule(:recursive_variation) do |r|
|
57
|
+
#r["(", :element_sequence, ")"]
|
58
|
+
#end
|
59
|
+
|
60
|
+
rule(
|
61
|
+
:string => %r{
|
62
|
+
" # beginning of string
|
63
|
+
(
|
64
|
+
[[:print:]&&[^\\"]] | # printing characters except quote and backslash
|
65
|
+
\\\\ | # escaped backslashes
|
66
|
+
\\" # escaped quotation marks
|
67
|
+
)* # zero or more of the above
|
68
|
+
" # end of string
|
69
|
+
}x
|
70
|
+
)
|
71
|
+
|
72
|
+
rule(
|
73
|
+
:game_termination => %r{
|
74
|
+
1-0 | # white wins
|
75
|
+
0-1 | # black wins
|
76
|
+
1\/2-1\/2 | # draw
|
77
|
+
\* # ?
|
78
|
+
}x
|
79
|
+
)
|
80
|
+
|
81
|
+
rule(
|
82
|
+
:move_number_indication => %r{
|
83
|
+
[[:digit:]]+\.* # one or more digits followed by zero or more periods
|
84
|
+
}x
|
85
|
+
)
|
86
|
+
|
87
|
+
rule(
|
88
|
+
:san_move => %r{
|
89
|
+
(
|
90
|
+
O(-O){1,2} | # castling (O-O, O-O-O)
|
91
|
+
[a-h][1-8] | # pawn moves (e4, d7)
|
92
|
+
[BKNQR][a-h1-8]?x?[a-h][1-8] | # major piece moves w/ optional specifier
|
93
|
+
# and capture
|
94
|
+
# (Bd2, N4c3, Raxc1)
|
95
|
+
[a-h][1-8]?x[a-h][1-8] # pawn captures
|
96
|
+
)
|
97
|
+
(
|
98
|
+
=[BNQR] # optional promotion (d8=Q)
|
99
|
+
)?
|
100
|
+
(
|
101
|
+
\+ | # check (g5+)
|
102
|
+
\# # checkmate (Qe7#)
|
103
|
+
)?
|
104
|
+
}x
|
105
|
+
)
|
106
|
+
|
107
|
+
rule(
|
108
|
+
:tag_name => %r{
|
109
|
+
[A-Za-z0-9_]+ # letters, digits and underscores only
|
110
|
+
}x
|
111
|
+
)
|
112
|
+
|
113
|
+
rule(
|
114
|
+
:numeric_annotation_glyph => %r{
|
115
|
+
\$\d+ # dollar sign followed by an integer from 0 to 255
|
116
|
+
}x
|
117
|
+
)
|
118
|
+
end
|
119
|
+
end
|
data/lib/pgn/position.rb
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
module PGN
|
2
|
+
# {PGN::Position} encapsulates all of the information necessary to
|
3
|
+
# completely understand a chess position. It can be turned into a FEN string
|
4
|
+
# or perform a move.
|
5
|
+
#
|
6
|
+
# @!attribute board
|
7
|
+
# @return [PGN::Board] the board for the position
|
8
|
+
#
|
9
|
+
# @!attribute player
|
10
|
+
# @return [Symbol] the player who moves next
|
11
|
+
# @example
|
12
|
+
# position.player #=> :white
|
13
|
+
#
|
14
|
+
# @!attribute castling
|
15
|
+
# @return [Array<String>] the castling moves that are still available
|
16
|
+
# @example
|
17
|
+
# position.castling #=> ["K", "k", "q"]
|
18
|
+
#
|
19
|
+
# @!attribute en_passant
|
20
|
+
# @return [String] the en passant square if applicable
|
21
|
+
#
|
22
|
+
# @!attribute halfmove
|
23
|
+
# @return [Integer] the number of halfmoves since the last pawn move or
|
24
|
+
# capture
|
25
|
+
#
|
26
|
+
# @!attribute fullmove
|
27
|
+
# @return [Integer] the number of fullmoves made so far
|
28
|
+
#
|
29
|
+
class Position
|
30
|
+
PLAYERS = [:white, :black]
|
31
|
+
CASTLING = %w{K Q k q}
|
32
|
+
|
33
|
+
attr_accessor :board
|
34
|
+
attr_accessor :player
|
35
|
+
attr_accessor :castling
|
36
|
+
attr_accessor :en_passant
|
37
|
+
attr_accessor :halfmove
|
38
|
+
attr_accessor :fullmove
|
39
|
+
|
40
|
+
# @return [PGN::Position] the starting position of a chess game
|
41
|
+
#
|
42
|
+
def self.start
|
43
|
+
PGN::Position.new(
|
44
|
+
PGN::Board.start,
|
45
|
+
PLAYERS.first,
|
46
|
+
castling: CASTLING,
|
47
|
+
en_passant: nil,
|
48
|
+
halfmove: 0,
|
49
|
+
fullmove: 0,
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
# @param board [PGN::Board] the board for the position
|
54
|
+
# @param player [Symbol] the player who moves next
|
55
|
+
# @param castling [Array<String>] the castling moves that are still
|
56
|
+
# available
|
57
|
+
# @param en_passant [String, nil] the en passant square if applicable
|
58
|
+
# @param halfmove [Integer] the number of halfmoves since the last pawn
|
59
|
+
# move or capture
|
60
|
+
# @param fullmove [Integer] the number of fullmoves made so far
|
61
|
+
#
|
62
|
+
# @example
|
63
|
+
# PGN::Position.new(
|
64
|
+
# PGN::Board.start,
|
65
|
+
# :white,
|
66
|
+
# )
|
67
|
+
#
|
68
|
+
def initialize(board, player, castling: CASTLING, en_passant: nil, halfmove: 0, fullmove: 0)
|
69
|
+
self.board = board
|
70
|
+
self.player = player
|
71
|
+
self.castling = castling
|
72
|
+
self.en_passant = en_passant
|
73
|
+
self.halfmove = halfmove
|
74
|
+
self.fullmove = fullmove
|
75
|
+
end
|
76
|
+
|
77
|
+
# @param str [String] the move to make in SAN
|
78
|
+
# @return [PGN::Position] the resulting position
|
79
|
+
#
|
80
|
+
# @example
|
81
|
+
# queens_pawn = PGN::Position.start.move("d4")
|
82
|
+
#
|
83
|
+
def move(str)
|
84
|
+
move = PGN::Move.new(str, self.player)
|
85
|
+
calculator = PGN::MoveCalculator.new(self.board, move)
|
86
|
+
|
87
|
+
new_castling = self.castling - calculator.castling_restrictions
|
88
|
+
new_halfmove = calculator.increment_halfmove? ?
|
89
|
+
self.halfmove + 1 :
|
90
|
+
0
|
91
|
+
new_fullmove = calculator.increment_fullmove? ?
|
92
|
+
self.fullmove + 1 :
|
93
|
+
self.fullmove
|
94
|
+
|
95
|
+
PGN::Position.new(
|
96
|
+
calculator.result_board,
|
97
|
+
self.next_player,
|
98
|
+
castling: new_castling,
|
99
|
+
en_passant: calculator.en_passant_square,
|
100
|
+
halfmove: new_halfmove,
|
101
|
+
fullmove: new_fullmove,
|
102
|
+
)
|
103
|
+
end
|
104
|
+
|
105
|
+
# @return [Symbol] the next player to move
|
106
|
+
#
|
107
|
+
def next_player
|
108
|
+
(PLAYERS - [self.player]).first
|
109
|
+
end
|
110
|
+
|
111
|
+
def inspect
|
112
|
+
"\n" + self.board.inspect
|
113
|
+
end
|
114
|
+
|
115
|
+
# @return [PGN::FEN] a {PGN::FEN} object representing the current position
|
116
|
+
#
|
117
|
+
def to_fen
|
118
|
+
PGN::FEN.from_attributes(
|
119
|
+
board: self.board,
|
120
|
+
active: self.player == :white ? 'w' : 'b',
|
121
|
+
castling: self.castling.join(''),
|
122
|
+
en_passant: self.en_passant,
|
123
|
+
halfmove: self.halfmove.to_s,
|
124
|
+
fullmove: self.fullmove.to_s,
|
125
|
+
)
|
126
|
+
end
|
127
|
+
|
128
|
+
end
|
129
|
+
end
|
data/lib/pgn/version.rb
ADDED
data/pgn.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'pgn/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "pgn"
|
8
|
+
spec.version = PGN::VERSION
|
9
|
+
spec.authors = ["Stacey Touset"]
|
10
|
+
spec.email = ["stacey@touset.org"]
|
11
|
+
spec.description = %q{A PGN parser and FEN generator for Ruby}
|
12
|
+
spec.summary = %q{A PGN parser for Ruby}
|
13
|
+
spec.homepage = "https://github.com/capicue/pgn"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($/)
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency "whittle"
|
22
|
+
|
23
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
24
|
+
spec.add_development_dependency "rake"
|
25
|
+
spec.add_development_dependency "rspec"
|
26
|
+
end
|
data/spec/fen_spec.rb
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe PGN::FEN do
|
4
|
+
describe "castling availability" do
|
5
|
+
it "should remove all castling availabilitiy after castling" do
|
6
|
+
pos = PGN::FEN.new("rnbqk2r/1p3pbp/p2p1np1/2pP4/P3PB2/2N2N2/1P3PPP/R2QKB1R b KQkq e3 0 8").to_position
|
7
|
+
next_pos = pos.move("O-O")
|
8
|
+
next_pos.to_fen.castling.should_not match(/k|q/)
|
9
|
+
end
|
10
|
+
|
11
|
+
it "should remove all castling availability after moving a king" do
|
12
|
+
pos = PGN::FEN.new("r1b1kb1r/pp2pppp/2n5/4P3/1nB5/P4N2/1P3PPP/RNBqK2R w KQkq - 0 9").to_position
|
13
|
+
next_pos = pos.move("Kxd1")
|
14
|
+
next_pos.to_fen.castling.should_not match(/K|Q/)
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should remove only the one castling option after moving a rook" do
|
18
|
+
pos = PGN::FEN.new("r3k2r/1pp2pp1/p1pb1qn1/4p3/3PP1p1/8/PPPN1PPN/R1BQR1K1 b kq - 1 11").to_position
|
19
|
+
next_pos = pos.move("Rxh2")
|
20
|
+
next_pos.to_fen.castling.should_not match(/k/)
|
21
|
+
next_pos.to_fen.castling.should match(/q/)
|
22
|
+
end
|
23
|
+
|
24
|
+
it "should change to a hyphen once no side can castle" do
|
25
|
+
pos = PGN::FEN.new("r1bq1rk1/pp1nbppp/3ppn2/8/2PP1N2/P1N5/1P2BPPP/R1BQK2R w KQ - 2 9").to_position
|
26
|
+
pos.to_fen.castling.should_not == "-"
|
27
|
+
next_pos = pos.move("O-O")
|
28
|
+
next_pos.to_fen.castling.should == "-"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
describe "en passant" do
|
33
|
+
it "should display the en passant square whenever a pawn moves two spaces" do
|
34
|
+
pos = PGN::FEN.new("rnbqkb1r/pppppppp/5n2/8/8/5N2/PPPPPPPP/RNBQKB1R w KQkq - 2 1").to_position
|
35
|
+
next_pos = pos.move("c4")
|
36
|
+
next_pos.to_fen.en_passant.should == "c3"
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should be a hyphen if no pawn moved two spaces the previous move" do
|
40
|
+
pos = PGN::FEN.new("rnbqkb1r/pppppppp/5n2/8/2P5/5N2/PP1PPPPP/RNBQKB1R b KQkq c3 0 1").to_position
|
41
|
+
next_pos = pos.move("d6")
|
42
|
+
next_pos.to_fen.en_passant.should == "-"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
describe "halfmove counter" do
|
47
|
+
it "should reset after a pawn advance" do
|
48
|
+
pos = PGN::FEN.new("2b2rk1/2pp1ppp/1p6/r3P2q/3Q4/2P5/PP3PPP/RN3RK1 w - - 3 15").to_position
|
49
|
+
pos.to_fen.halfmove.should == "3"
|
50
|
+
next_pos = pos.move("f4")
|
51
|
+
next_pos.to_fen.halfmove.should == "0"
|
52
|
+
end
|
53
|
+
|
54
|
+
it "should reset after a capture" do
|
55
|
+
pos = PGN::FEN.new("2r2rk1/1p2ppbp/1q1p1np1/pN4B1/Pnb1PP2/2N5/1PP1B1PP/R2Q1R1K w - - 5 14").to_position
|
56
|
+
pos.to_fen.halfmove.should == "5"
|
57
|
+
next_pos = pos.move("Bxc4")
|
58
|
+
next_pos.to_fen.halfmove.should == "0"
|
59
|
+
end
|
60
|
+
|
61
|
+
it "should not reset otherwise" do
|
62
|
+
pos = PGN::FEN.new("2k2b1r/p4p1p/q1p2p2/8/2br4/5P2/PPQB2PP/R1N1K2R w KQ - 0 17").to_position
|
63
|
+
pos.to_fen.halfmove.should == "0"
|
64
|
+
moves = %w{Qf5+ Rd7 Bc3 Bh6 Qa5 Re8+ Kf2 Be3+ Kg3 Rg8+ Kh4}
|
65
|
+
moves.each_with_index do |move, i|
|
66
|
+
pos = pos.move(move)
|
67
|
+
pos.to_fen.halfmove.should == (i+1).to_s
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe "fullmove counter" do
|
73
|
+
it "should not increase after white moves" do
|
74
|
+
pos = PGN::FEN.new("4br1k/ppqnr1b1/3p3p/P1pP1p2/2P1pB2/6PP/1P2BP1N/R2QR1K1 w - - 3 25").to_position
|
75
|
+
pos.to_fen.fullmove.should == "25"
|
76
|
+
next_pos = pos.move("Qd2")
|
77
|
+
next_pos.to_fen.fullmove.should == "25"
|
78
|
+
end
|
79
|
+
|
80
|
+
it "should increase after black moves" do
|
81
|
+
pos = PGN::FEN.new("4br1k/ppqnr1b1/3p3p/P1pP1p2/2P1pB2/6PP/1P1QBP1N/R3R1K1 b - - 4 25").to_position
|
82
|
+
pos.to_fen.fullmove.should == "25"
|
83
|
+
next_pos = pos.move("Kh7")
|
84
|
+
next_pos.to_fen.fullmove.should == "26"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
data/spec/game_spec.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe PGN::Game do
|
4
|
+
describe "#positions" do
|
5
|
+
it "should not raise an error" do
|
6
|
+
tags = {"White" => "Deep Blue", "Black" => "Kasparov"}
|
7
|
+
moves = %w{e4 c5 c3 d5 exd5 Qxd5 d4 Nf6 Nf3 Bg4 Be2 e6 h3 Bh5 O-O Nc6 Be3 cxd4 cxd4 Bb4 a3 Ba5 Nc3 Qd6 Nb5 Qe7 Ne5 Bxe2 Qxe2 O-O Rac1 Rac8 Bg5 Bb6 Bxf6 gxf6 Nc4 Rfd8 Nxb6 axb6 Rfd1 f5 Qe3 Qf6 d5 Rxd5 Rxd5 exd5 b3 Kh8 Qxb6 Rg8 Qc5 d4 Nd6 f4 Nxb7 Ne5 Qd5 f3 g3 Nd3 Rc7 Re8 Nd6 Re1+ Kh2 Nxf2 Nxf7+ Kg7 Ng5+ Kh6 Rxh7+}
|
8
|
+
result = "1-0"
|
9
|
+
game = PGN::Game.new(moves, tags, result)
|
10
|
+
lambda { game.positions }.should_not raise_error
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
data/spec/parser_spec.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe PGN do
|
4
|
+
describe "parsing a file" do
|
5
|
+
it "should return a list of games" do
|
6
|
+
games = PGN.parse(File.read("./examples/immortal_game.pgn"))
|
7
|
+
games.length.should == 1
|
8
|
+
game = games.first
|
9
|
+
game.result.should == "1-0"
|
10
|
+
game.tags["White"].should == "Adolf Anderssen"
|
11
|
+
game.moves.last.should == "Be7#"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe PGN::Position do
|
4
|
+
context "disambiguating moves" do
|
5
|
+
describe "using SAN square disambiguation" do
|
6
|
+
pos = PGN::FEN.new("r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6").to_position
|
7
|
+
next_pos = pos.move("Ndb5")
|
8
|
+
|
9
|
+
it "should move the specified piece" do
|
10
|
+
next_pos.board.at("d4").should be_nil
|
11
|
+
end
|
12
|
+
|
13
|
+
it "should not move the other piece" do
|
14
|
+
next_pos.board.at("c3").should == "N"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
describe "using discovered check" do
|
19
|
+
pos = PGN::FEN.new("rnbqk2r/p1pp1ppp/1p2pn2/8/1bPP4/2N1P3/PP3PPP/R1BQKBNR w KQkq - 0 5").to_position
|
20
|
+
next_pos = pos.move("Ne2")
|
21
|
+
|
22
|
+
it "should move the piece that doesn't give discovered check" do
|
23
|
+
next_pos.board.at("g1").should be_nil
|
24
|
+
end
|
25
|
+
|
26
|
+
it "shouldn't move the other piece" do
|
27
|
+
next_pos.board.at("c3").should == "N"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
describe "with two pawns on the same file" do
|
32
|
+
pos = PGN::FEN.new("r2q1rk1/4bppp/p3n3/1p2n3/4N3/1B2BP2/PP3P1P/R2Q1RK1 w - - 4 19").to_position
|
33
|
+
next_pos = pos.move("f4")
|
34
|
+
|
35
|
+
it "should move the pawn in front" do
|
36
|
+
next_pos.board.at("f3").should be_nil
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should not move the other pawn" do
|
40
|
+
next_pos.board.at("f2").should == "P"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|