pgn2 0.4.0 → 1.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +50 -0
- data/.github/workflows/publish.yml +75 -0
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +52 -0
- data/README.md +104 -4
- data/Rakefile +20 -0
- data/bench/.keep +0 -0
- data/bench/IMPROVEMENTS.md +75 -0
- data/bench/baseline_moves.pre-optimization.txt +22 -0
- data/bench/baseline_moves.txt +22 -0
- data/bench/baseline_parse.pre-optimization.txt +25 -0
- data/bench/baseline_parse.racc.txt +25 -0
- data/bench/baseline_parse.txt +25 -0
- data/bench/profile_moves.rb +53 -0
- data/bench/profile_parse.rb +44 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
- data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
- data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
- data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
- data/lib/pgn/board.rb +33 -15
- data/lib/pgn/fen.rb +16 -8
- data/lib/pgn/game.rb +11 -2
- data/lib/pgn/lexer.rb +201 -0
- data/lib/pgn/move.rb +7 -3
- data/lib/pgn/move_calculator.rb +18 -17
- data/lib/pgn/parser.rb +19 -199
- data/lib/pgn/pgn_parser.rb +392 -0
- data/lib/pgn/pgn_parser.y +142 -0
- data/lib/pgn/serializer.rb +141 -0
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +3 -0
- data/pgn2.gemspec +12 -2
- data/spec/board_spec.rb +111 -0
- data/spec/fen_spec.rb +25 -0
- data/spec/game_spec.rb +74 -0
- data/spec/lexer_spec.rb +153 -0
- data/spec/move_calculator_spec.rb +226 -0
- data/spec/move_spec.rb +136 -0
- data/spec/parser_explicit_spec.rb +210 -0
- data/spec/parser_spec.rb +15 -0
- data/spec/pgn_files/doublequotes.pgn +21 -0
- data/spec/pgn_files/specialcharacters.pgn +79 -0
- data/spec/position_spec.rb +73 -0
- data/spec/serializer_spec.rb +89 -0
- data/spec/spec_helper.rb +0 -1
- metadata +99 -15
data/spec/move_spec.rb
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe PGN::Move do
|
|
4
|
+
describe 'pawn pushes' do
|
|
5
|
+
it 'parses a white pawn push' do
|
|
6
|
+
m = PGN::Move.new('e4', :white)
|
|
7
|
+
expect(m.piece).to eq('P')
|
|
8
|
+
expect(m.destination).to eq('e4')
|
|
9
|
+
expect(m.capture).to eq(false)
|
|
10
|
+
expect(m.pawn?).to eq(true)
|
|
11
|
+
expect(m.white?).to eq(true)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'parses a black pawn push' do
|
|
15
|
+
m = PGN::Move.new('d5', :black)
|
|
16
|
+
expect(m.piece).to eq('p')
|
|
17
|
+
expect(m.destination).to eq('d5')
|
|
18
|
+
expect(m.pawn?).to eq(true)
|
|
19
|
+
expect(m.black?).to eq(true)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe 'pawn captures' do
|
|
24
|
+
it 'parses a pawn capture, using the file as disambiguation' do
|
|
25
|
+
m = PGN::Move.new('exd5', :white)
|
|
26
|
+
expect(m.piece).to eq('P')
|
|
27
|
+
expect(m.capture).to eq(true)
|
|
28
|
+
expect(m.disambiguation).to eq('e')
|
|
29
|
+
expect(m.destination).to eq('d5')
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe 'piece moves' do
|
|
34
|
+
it 'parses knight, bishop, queen, king moves' do
|
|
35
|
+
expect(PGN::Move.new('Nf3', :white).piece).to eq('N')
|
|
36
|
+
expect(PGN::Move.new('Bc4', :white).piece).to eq('B')
|
|
37
|
+
expect(PGN::Move.new('Qd1', :white).piece).to eq('Q')
|
|
38
|
+
expect(PGN::Move.new('Ke2', :white).piece).to eq('K')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'parses black piece moves as lowercase' do
|
|
42
|
+
expect(PGN::Move.new('Nf6', :black).piece).to eq('n')
|
|
43
|
+
expect(PGN::Move.new('Qd8', :black).piece).to eq('q')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'is not a pawn for piece moves' do
|
|
47
|
+
expect(PGN::Move.new('Nf3', :white).pawn?).to eq(false)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe 'disambiguation' do
|
|
52
|
+
it 'parses file disambiguation' do
|
|
53
|
+
m = PGN::Move.new('Nbd2', :white)
|
|
54
|
+
expect(m.piece).to eq('N')
|
|
55
|
+
expect(m.disambiguation).to eq('b')
|
|
56
|
+
expect(m.destination).to eq('d2')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'parses rank disambiguation' do
|
|
60
|
+
m = PGN::Move.new('N4c3', :white)
|
|
61
|
+
expect(m.disambiguation).to eq('4')
|
|
62
|
+
expect(m.destination).to eq('c3')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'parses a capturing disambiguated rook move' do
|
|
66
|
+
m = PGN::Move.new('Raxc1', :white)
|
|
67
|
+
expect(m.piece).to eq('R')
|
|
68
|
+
expect(m.disambiguation).to eq('a')
|
|
69
|
+
expect(m.capture).to eq(true)
|
|
70
|
+
expect(m.destination).to eq('c1')
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
describe 'promotion' do
|
|
75
|
+
it 'parses a white promotion' do
|
|
76
|
+
m = PGN::Move.new('e8=Q', :white)
|
|
77
|
+
expect(m.piece).to eq('P')
|
|
78
|
+
expect(m.destination).to eq('e8')
|
|
79
|
+
expect(m.promotion).to eq('Q')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'lowercases the promotion piece for black' do
|
|
83
|
+
m = PGN::Move.new('b8=Q', :black)
|
|
84
|
+
expect(m.promotion).to eq('q')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'parses a capturing promotion' do
|
|
88
|
+
m = PGN::Move.new('exd8=Q', :white)
|
|
89
|
+
expect(m.capture).to eq(true)
|
|
90
|
+
expect(m.disambiguation).to eq('e')
|
|
91
|
+
expect(m.destination).to eq('d8')
|
|
92
|
+
expect(m.promotion).to eq('Q')
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
describe 'check and mate' do
|
|
97
|
+
it 'parses a checking move' do
|
|
98
|
+
m = PGN::Move.new('g5+', :white)
|
|
99
|
+
expect(m.check).to eq('+')
|
|
100
|
+
expect(m.check?).to eq(true)
|
|
101
|
+
expect(m.checkmate?).to eq(false)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'parses a checkmate move' do
|
|
105
|
+
m = PGN::Move.new('Qe7#', :white)
|
|
106
|
+
expect(m.check).to eq('#')
|
|
107
|
+
expect(m.checkmate?).to eq(true)
|
|
108
|
+
expect(m.check?).to eq(false)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
describe 'castling' do
|
|
113
|
+
it 'parses white kingside and queenside' do
|
|
114
|
+
expect(PGN::Move.new('O-O', :white).castle).to eq('K')
|
|
115
|
+
expect(PGN::Move.new('O-O-O', :white).castle).to eq('Q')
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'parses black kingside and queenside (lowercase)' do
|
|
119
|
+
expect(PGN::Move.new('O-O', :black).castle).to eq('k')
|
|
120
|
+
expect(PGN::Move.new('O-O-O', :black).castle).to eq('q')
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'has nil piece for castling moves' do
|
|
124
|
+
expect(PGN::Move.new('O-O', :white).piece).to be_nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
describe "the don't-care move" do
|
|
129
|
+
it 'parses -- as a no-op with no piece or destination' do
|
|
130
|
+
m = PGN::Move.new('--', :white)
|
|
131
|
+
expect(m.piece).to be_nil
|
|
132
|
+
expect(m.destination).to be_nil
|
|
133
|
+
expect(m.pawn?).to eq(false)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'pgn'
|
|
3
|
+
|
|
4
|
+
# Explicit, hardcoded expectations for the Racc + StringScanner parser.
|
|
5
|
+
# These do not reference the legacy whittle parser and survive its removal;
|
|
6
|
+
# they pin the public PGN.parse behavior on the features exercised by the
|
|
7
|
+
# fixtures plus representative edge cases.
|
|
8
|
+
RSpec.describe PGN, '.parse (racc parser)' do
|
|
9
|
+
def notations(moves)
|
|
10
|
+
moves.map(&:notation)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def variations_as(move)
|
|
14
|
+
(move.variations || []).map { |v| notations(v) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe 'results' do
|
|
18
|
+
it 'parses a 1-0 result' do
|
|
19
|
+
game = PGN.parse('[White "A"] 1. e4 e5 1-0').first
|
|
20
|
+
expect(game.result).to eq('1-0')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'parses a 0-1 result' do
|
|
24
|
+
game = PGN.parse('[White "A"] 1. e4 e5 0-1').first
|
|
25
|
+
expect(game.result).to eq('0-1')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'parses a 1/2-1/2 draw result' do
|
|
29
|
+
game = PGN.parse('[White "A"] 1. e4 e5 1/2-1/2').first
|
|
30
|
+
expect(game.result).to eq('1/2-1/2')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'parses a * (unknown) result' do
|
|
34
|
+
game = PGN.parse('[White "A"] 1. e4 e5 *').first
|
|
35
|
+
expect(game.result).to eq('*')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'parses a result-only (empty movetext) game' do
|
|
39
|
+
game = PGN.parse('[Event "?"] *').first
|
|
40
|
+
expect(game.result).to eq('*')
|
|
41
|
+
expect(game.moves).to be_empty
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe 'tags' do
|
|
46
|
+
it 'extracts tag values (stripping surrounding quotes)' do
|
|
47
|
+
game = PGN.parse(%([White "Kasparov"]\n[Black "Deep Blue"] 1. e4 1-0)).first
|
|
48
|
+
expect(game.tags['White']).to eq('Kasparov')
|
|
49
|
+
expect(game.tags['Black']).to eq('Deep Blue')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'keeps the first value on duplicate tag keys' do
|
|
53
|
+
game = PGN.parse(%([White "First"]\n[White "Second"] 1. e4 1-0)).first
|
|
54
|
+
expect(game.tags['White']).to eq('First')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'preserves unescaped double-quotes inside a tag value' do
|
|
58
|
+
game = PGN.parse(File.read('./spec/pgn_files/doublequotes.pgn')).first
|
|
59
|
+
expect(game.tags['Event']).to eq('IRT BLITZ "Sub Zonal"')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'honors the Encoding argument for multibyte tag values' do
|
|
63
|
+
text = File.read('./spec/pgn_files/specialcharacters.pgn')
|
|
64
|
+
games = PGN.parse(text, Encoding::UTF_8)
|
|
65
|
+
expect(games.first.tags['WhiteTeam']).to eq('NARIÑO')
|
|
66
|
+
expect(games.last.tags['Site']).to eq(
|
|
67
|
+
'HOTEL CAFEIRA Calle 18 Nro. 5 – 38 Centro de Pereira – Risaralda'
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
describe 'san moves' do
|
|
73
|
+
it 'parses pawn moves and captures' do
|
|
74
|
+
game = PGN.parse('[White "A"] 1. e4 d5 2. exd5 1-0').first
|
|
75
|
+
expect(notations(game.moves)).to eq(%w[e4 d5 exd5])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'parses promotion with check' do
|
|
79
|
+
game = PGN.parse('[White "A"] 1. e7 d8=Q+ 1-0').first
|
|
80
|
+
expect(game.moves[-1].notation).to eq('d8=Q+')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'parses check and checkmate' do
|
|
84
|
+
game = PGN.parse('[White "A"] 1. Qe7+ Qxe7 2. Qxf7# 1-0').first
|
|
85
|
+
expect(notations(game.moves)).to eq(%w[Qe7+ Qxe7 Qxf7#])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'parses castling (O-O and O-O-O)' do
|
|
89
|
+
game = PGN.parse('[White "A"] 1. O-O O-O-O 1-0').first
|
|
90
|
+
expect(notations(game.moves)).to eq(%w[O-O O-O-O])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'normalizes 0-form castling to O-form in move notation' do
|
|
94
|
+
game = PGN.parse(File.read('./spec/pgn_files/alternate_castling.pgn')).first
|
|
95
|
+
expect(game.moves.last.notation).to eq('O-O-O')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'parses major-piece moves with disambiguation' do
|
|
99
|
+
game = PGN.parse('[White "A"] 1. Nf3 Nc6 2. Nef6 Nef6 1-0').first
|
|
100
|
+
expect(notations(game.moves)).to eq(%w[Nf3 Nc6 Nef6 Nef6])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'parses the dont-care move (--) inside a variation' do
|
|
104
|
+
game = PGN.parse('[White "A"] 1. e4 (1. -- d5) e5 1-0').first
|
|
105
|
+
expect(variations_as(game.moves.first)).to eq([%w[-- d5]])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe 'annotations' do
|
|
110
|
+
it 'parses numeric annotation glyphs' do
|
|
111
|
+
game = PGN.parse('[White "A"] 1. e4 e5 2. Nf3 $1 Nc6 1-0').first
|
|
112
|
+
expect(game.moves[2].annotation).to eq(['$1'])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'parses punctuation annotations (!?, ?!, !!, ??)' do
|
|
116
|
+
game = PGN.parse('[White "A"] 1. e4!? e5?! 2. Nf3 Nc6 1-0').first
|
|
117
|
+
expect(game.moves[0].annotation).to eq(['!?'])
|
|
118
|
+
expect(game.moves[1].annotation).to eq(['?!'])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'parses multiple annotations on one move' do
|
|
122
|
+
game = PGN.parse(File.read('./spec/pgn_files/two_annotations.pgn')).first
|
|
123
|
+
expect(game.moves[1].annotation).to eq(['$2', '$11'])
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
describe 'comments' do
|
|
128
|
+
it 'attaches a comment after a move and cleans whitespace' do
|
|
129
|
+
game = PGN.parse('[White "A"] 1. e4 { a comment } e5 1-0').first
|
|
130
|
+
expect(game.moves[0].comment).to eq('a comment')
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'parses a multiline comment and collapses whitespace' do
|
|
134
|
+
game = PGN.parse(File.read('./spec/pgn_files/multiline_comments.pgn')).first
|
|
135
|
+
expect(game.moves[5].comment).to eq('I pity the fool!')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'parses nested brace comments (lexer recurses inner braces)' do
|
|
139
|
+
game = PGN.parse(File.read('./spec/pgn_files/nested_comments.pgn')).first
|
|
140
|
+
# The lexer recurses and captures the full nested comment; the exact
|
|
141
|
+
# cleaned text is sensitive to the (pre-existing, shared with whittle)
|
|
142
|
+
# double-clean in Game#moves=, so assert stable substrings.
|
|
143
|
+
comment = game.moves[2].comment
|
|
144
|
+
expect(comment).to include('nested')
|
|
145
|
+
expect(comment).to include('comment')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'captures a standalone comment as the game comment (with braces)' do
|
|
149
|
+
game = PGN.parse(File.read('./spec/pgn_files/no_moves.pgn')).last
|
|
150
|
+
expect(game.comment).to eq('{game comment}')
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
describe 'variations' do
|
|
155
|
+
it 'parses a single variation attached to a move' do
|
|
156
|
+
game = PGN.parse('[White "A"] 1. e4 (1. d4 d5) e5 1-0').first
|
|
157
|
+
expect(variations_as(game.moves.first)).to eq([%w[d4 d5]])
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it 'parses a nested variation' do
|
|
161
|
+
game = PGN.parse('[White "A"] 1. e4 (1. d4 (1... d5) dxc4) e5 1-0').first
|
|
162
|
+
outer = game.moves.first.variations.first
|
|
163
|
+
expect(notations(outer)).to eq(%w[d4 dxc4])
|
|
164
|
+
expect(variations_as(outer[0])).to eq([%w[d5]])
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'compares multiple variations as an order-independent set' do
|
|
168
|
+
game = PGN.parse('[White "A"] 1. e4 (1. d4) (1. Nf3) e5 1-0').first
|
|
169
|
+
expect(variations_as(game.moves.first).sort).to eq([%w[d4], %w[Nf3]].sort)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it 'parses a variation that itself contains an empty move' do
|
|
173
|
+
game = PGN.parse(File.read('./spec/pgn_files/empty_variation_move.pgn')).first
|
|
174
|
+
expect(game.result).to eq('*')
|
|
175
|
+
expect(game.moves.last.notation).to eq('Ng5')
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
describe 'pgn (verbatim raw text)' do
|
|
180
|
+
it 'returns the verbatim text for a single game' do
|
|
181
|
+
input = "[White \"A\"] 1. e4 1-0\n"
|
|
182
|
+
expect(PGN.parse(input).first.pgn).to eq(input)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'partitions consecutive games contiguously' do
|
|
186
|
+
input = "[White \"A\"] 1. e4 1-0\n\n[White \"B\"] 1. d4 0-1\n"
|
|
187
|
+
games = PGN.parse(input)
|
|
188
|
+
expect(games.map(&:pgn)).to eq(["[White \"A\"] 1. e4 1-0\n\n", "[White \"B\"] 1. d4 0-1\n"])
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'includes a leading percent comment in the first game pgn' do
|
|
192
|
+
input = "% header\n[White \"A\"] 1. e4 *\n"
|
|
193
|
+
expect(PGN.parse(input).first.pgn).to eq(input)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
describe 'multiple games' do
|
|
198
|
+
it 'returns games in source order' do
|
|
199
|
+
games = PGN.parse("[White \"One\"] 1. e4 1-0\n\n[White \"Two\"] 1. d4 0-1")
|
|
200
|
+
expect(games.map { |g| g.tags['White'] }).to eq(%w[One Two])
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
describe 'starting position (FEN tag)' do
|
|
205
|
+
it 'uses the FEN tag for positions' do
|
|
206
|
+
game = PGN.parse(File.read('./spec/pgn_files/fen.pgn')).first
|
|
207
|
+
expect(game.positions.first.to_fen.to_s).to eq(game.tags['FEN'])
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
data/spec/parser_spec.rb
CHANGED
|
@@ -139,5 +139,20 @@ describe PGN do
|
|
|
139
139
|
game = games.last
|
|
140
140
|
expect(game.comment).to eq('{game comment}')
|
|
141
141
|
end
|
|
142
|
+
|
|
143
|
+
it 'tag_values with double quotes' do
|
|
144
|
+
games = PGN.parse(File.read('./spec/pgn_files/doublequotes.pgn'))
|
|
145
|
+
game = games.last
|
|
146
|
+
expect(game.tags['Event']).to eq('IRT BLITZ "Sub Zonal"')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'set encoding' do
|
|
150
|
+
file = File.read('./spec/pgn_files/specialcharacters.pgn')
|
|
151
|
+
games = PGN.parse(file, Encoding::UTF_8)
|
|
152
|
+
game1 = games.first
|
|
153
|
+
game2 = games.last
|
|
154
|
+
expect(game1.tags['WhiteTeam']).to eq('NARIÑO')
|
|
155
|
+
expect(game2.tags['Site']).to eq('HOTEL CAFEIRA Calle 18 Nro. 5 – 38 Centro de Pereira – Risaralda')
|
|
156
|
+
end
|
|
142
157
|
end
|
|
143
158
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[Event "IRT BLITZ "Sub Zonal""]
|
|
2
|
+
[Site "HOTEL CAFEIRA Calle 18 Nro. 5 - 38 Centro de Pereira Risaralda"]
|
|
3
|
+
[Date "2021.11.27"]
|
|
4
|
+
[Round "5"]
|
|
5
|
+
[Board "2"]
|
|
6
|
+
[White "Estrada Velandia, Henry Andres"]
|
|
7
|
+
[Black "Vargas Arteaga, Alexis"]
|
|
8
|
+
[Result "0-1"]
|
|
9
|
+
[ECO "D00"]
|
|
10
|
+
[WhiteElo "2055"]
|
|
11
|
+
[BlackElo "2343"]
|
|
12
|
+
[PlyCount "64"]
|
|
13
|
+
[EventDate "2021.11.27"]
|
|
14
|
+
[EventRounds "18"]
|
|
15
|
+
[EventCountry "COL"]
|
|
16
|
+
|
|
17
|
+
1. d4 Nf6 2. c3 d5 3. e3 c5 4. Bd3 Nc6 5. f4 e6 6. Nf3 Bd6 7. O-O O-O 8. Bd2 b6
|
|
18
|
+
9. Be1 Ne7 10. Ne5 Nf5 11. Bxf5 exf5 12. Bh4 Be6 13. Nd2 Rc8 14. Qf3 Be7 15.
|
|
19
|
+
Bxf6 Bxf6 16. Kh1 g6 17. Rg1 b5 18. g4 b4 19. gxf5 Bxf5 20. Raf1 Bg7 21. Rg5
|
|
20
|
+
bxc3 22. bxc3 cxd4 23. Rxf5 dxc3 24. Nxf7 Qd7 25. Rxd5 Qxf7 26. Nb3 c2 27. Rc1
|
|
21
|
+
Bb2 28. Rg1 c1=Q 29. Nxc1 Rxc1 30. Rxc1 Bxc1 31. h4 Bxe3 32. f5 Bh6 0-1
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[Event "XXI Juegos Deportivos Nacionales AJEDREZ RAPIDO Absoluto por Equipos 2019"]
|
|
2
|
+
[Site "Cartagena (Bolivar-Colombia) Centro Amurallado, Palacio de la Proclamación"]
|
|
3
|
+
[Date "2019.11.29"]
|
|
4
|
+
[Round "1"]
|
|
5
|
+
[Board "1"]
|
|
6
|
+
[White "Pai, Juan David"]
|
|
7
|
+
[Black "Vargas Arteaga, Alexis"]
|
|
8
|
+
[Result "0-1"]
|
|
9
|
+
[ECO ""]
|
|
10
|
+
[WhiteElo "2008"]
|
|
11
|
+
[BlackElo "2339"]
|
|
12
|
+
[PlyCount "0"]
|
|
13
|
+
[EventDate "2019.11.29"]
|
|
14
|
+
[EventType "team"]
|
|
15
|
+
[EventRounds "7"]
|
|
16
|
+
[EventCountry "COL"]
|
|
17
|
+
[WhiteTeam "NARIÑO"]
|
|
18
|
+
[BlackTeam "SANTANDER"]
|
|
19
|
+
|
|
20
|
+
1. d4 {[%clk 00:15:18]} {[%emt 00:00:02]} Nf6 {[%clk 00:15:17]} {[%emt
|
|
21
|
+
00:00:03]} 2. Nf3 {[%clk 00:15:12]} {[%emt 00:00:16]} e6 {[%clk 00:15:25]}
|
|
22
|
+
{[%emt 00:00:01]} 3. c4 {[%clk 00:15:19]} {[%emt 00:00:03]} b6 {[%clk
|
|
23
|
+
00:15:30]} {[%emt 00:00:05]} 4. g3 {[%clk 00:15:28]} {[%emt 00:00:02]} Ba6
|
|
24
|
+
{[%clk 00:15:37]} {[%emt 00:00:02]} 5. b3 {[%clk 00:15:37]} {[%emt 00:00:02]}
|
|
25
|
+
b5 {[%clk 00:15:46]} {[%emt 00:00:01]} 6. Bg2 {[%clk 00:15:36]} {[%emt
|
|
26
|
+
00:00:12]} bxc4 {[%clk 00:15:54]} {[%emt 00:00:01]} 7. Ne5 {[%clk 00:15:45]}
|
|
27
|
+
Bb4+ {[%clk 00:16:03]} {[%emt 00:00:01]} 8. Bd2 {[%clk 00:15:54]} cxb3 {[%clk
|
|
28
|
+
00:16:12]} {[%emt 00:00:01]} 9. axb3 {[%clk 00:15:53]} {[%emt 00:00:11]} Bxd2+
|
|
29
|
+
{[%clk 00:16:18]} {[%emt 00:00:04]} 10. Qxd2 {[%clk 00:14:36]} {[%emt
|
|
30
|
+
00:01:27]} d5 {[%clk 00:16:27]} 11. O-O {[%clk 00:13:50]} {[%emt 00:00:57]} O-O
|
|
31
|
+
{[%clk 00:16:34]} {[%emt 00:00:03]} 12. Rc1 {[%clk 00:13:49]} {[%emt 00:00:11]}
|
|
32
|
+
Nfd7 {[%clk 00:16:15]} {[%emt 00:00:28]} 13. Nc6 {[%clk 00:11:27]} {[%emt
|
|
33
|
+
00:02:32]} Nxc6 {[%clk 00:16:23]} {[%emt 00:00:02]} 14. Rxa6 {[%clk 00:11:26]}
|
|
34
|
+
{[%emt 00:00:11]} Ncb8 {[%clk 00:16:06]} {[%emt 00:00:27]} 15. Ra5 {[%clk
|
|
35
|
+
00:11:12]} {[%emt 00:00:24]} a6 {[%clk 00:16:13]} {[%emt 00:00:02]} 16. Qb4
|
|
36
|
+
{[%clk 00:10:47]} {[%emt 00:00:36]} Ra7 {[%clk 00:16:17]} {[%emt 00:00:05]} 17.
|
|
37
|
+
Nc3 {[%clk 00:10:51]} {[%emt 00:00:07]} Nc6 {[%clk 00:16:15]} {[%emt 00:00:12]}
|
|
38
|
+
18. Qa4 {[%clk 00:10:45]} {[%emt 00:00:17]} Nxa5 {[%clk 00:16:23]} {[%emt
|
|
39
|
+
00:00:01]} 19. Qxa5 {[%clk 00:10:54]} {[%emt 00:00:01]} c5 {[%clk 00:16:22]}
|
|
40
|
+
{[%emt 00:00:11]} 20. Qxd8 {[%clk 00:10:27]} {[%emt 00:00:37]} Rxd8 {[%clk
|
|
41
|
+
00:16:30]} {[%emt 00:00:01]} 21. dxc5 {[%clk 00:10:36]} {[%emt 00:00:02]} Nxc5
|
|
42
|
+
{[%clk 00:16:23]} {[%emt 00:00:16]} 22. Nxd5 {[%clk 00:10:36]} {[%emt
|
|
43
|
+
00:00:12]} Nxb3 {[%clk 00:15:29]} {[%emt 00:01:03]} 23. Rc3 {[%clk 00:10:33]}
|
|
44
|
+
{[%emt 00:00:13]} Nd4 {[%clk 00:15:23]} {[%emt 00:00:16]} 24. Nf4 {[%clk
|
|
45
|
+
00:10:30]} {[%emt 00:00:13]} g5 {[%clk 00:15:21]} {[%emt 00:00:12]} 25. e3
|
|
46
|
+
{[%clk 00:10:25]} {[%emt 00:00:16]} gxf4 {[%clk 00:15:28]} {[%emt 00:00:02]} 26.
|
|
47
|
+
exd4 {[%clk 00:10:34]} {[%emt 00:00:02]} Rxd4 {[%clk 00:15:36]} {[%emt
|
|
48
|
+
00:00:01]} 27. gxf4 {[%clk 00:10:36]} {[%emt 00:00:08]} Rxf4 {[%clk 00:15:42]}
|
|
49
|
+
{[%emt 00:00:04]} 28. Rg3+ {[%clk 00:09:57]} {[%emt 00:00:48]} Kf8 {[%clk
|
|
50
|
+
00:15:49]} {[%emt 00:00:03]} 29. Rh3 {[%clk 00:10:06]} {[%emt 00:00:02]} f6
|
|
51
|
+
{[%clk 00:15:51]} {[%emt 00:00:07]} 30. Ra3 {[%clk 00:10:07]} {[%emt 00:00:10]}
|
|
52
|
+
a5 {[%clk 00:15:45]} {[%emt 00:00:16]} 31. Bc6 {[%clk 00:10:15]} {[%emt
|
|
53
|
+
00:00:01]} Ke7 {[%clk 00:15:43]} {[%emt 00:00:13]} 32. Rd3 {[%clk 00:10:23]}
|
|
54
|
+
{[%emt 00:00:02]} Rc7 {[%clk 00:15:42]} {[%emt 00:00:10]} 33. Ba4 {[%clk
|
|
55
|
+
00:10:12]} {[%emt 00:00:22]} Rxa4 {[%clk 00:15:49]} {[%emt 00:00:03]} 0-1
|
|
56
|
+
|
|
57
|
+
[Event "Subzonal 2.3.4 Absoluto COLOMBIA"]
|
|
58
|
+
[Site "HOTEL CAFEIRA Calle 18 Nro. 5 – 38 Centro de Pereira – Risaralda"]
|
|
59
|
+
[Date "2021.11.23"]
|
|
60
|
+
[Round "1"]
|
|
61
|
+
[Board "7"]
|
|
62
|
+
[White "Vargas Arteaga, Alexis"]
|
|
63
|
+
[Black "Murillo Loreo, Oscar Miguel"]
|
|
64
|
+
[Result "1-0"]
|
|
65
|
+
[ECO "B37"]
|
|
66
|
+
[WhiteElo "2277"]
|
|
67
|
+
[BlackElo "1775"]
|
|
68
|
+
[PlyCount "83"]
|
|
69
|
+
[EventDate "2021.11.23"]
|
|
70
|
+
[EventRounds "9"]
|
|
71
|
+
[EventCountry "COL"]
|
|
72
|
+
|
|
73
|
+
1. e4 c5 2. Nf3 Nc6 3. d4 cxd4 4. Nxd4 g6 5. c4 Bg7 6. Nc2 Nf6 7. Nc3 d6 8. Be2
|
|
74
|
+
O-O 9. O-O Nd7 10. Re1 Nc5 11. Bf1 Be6 12. Nd5 h6 13. Rb1 a5 14. b3 Kh7 15. Bb2
|
|
75
|
+
Bxb2 16. Rxb2 f5 17. exf5 Bxf5 18. Nd4 e5 19. Nb5 e4 20. Ne3 Ne5 21. Rd2 Ncd3
|
|
76
|
+
22. Bxd3 Nxd3 23. Nxf5 gxf5 24. Rxd3 exd3 25. Qxd3 Qf6 26. Qd5 Rae8 27. Rd1 Qe5
|
|
77
|
+
28. g3 Qxd5 29. Rxd5 f4 30. Kf1 f3 31. Rd2 Rf4 32. Nd4 a4 33. Rd3 axb3 34. axb3
|
|
78
|
+
Ref8 35. Re3 R8f7 36. h3 h5 37. h4 Kg6 38. Ne6 Kf5 39. Ng5 Rf8 40. Nh7 Kg4 41.
|
|
79
|
+
Re4 Kh3 42. Kg1 1-0
|
data/spec/position_spec.rb
CHANGED
|
@@ -52,3 +52,76 @@ describe PGN::Position do
|
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
|
+
|
|
56
|
+
# New exhaustive coverage below. Existing `should` examples above are
|
|
57
|
+
# intentionally left untouched.
|
|
58
|
+
|
|
59
|
+
describe PGN::Position do
|
|
60
|
+
describe '.start attributes' do
|
|
61
|
+
it 'has the expected starting state' do
|
|
62
|
+
pos = PGN::Position.start
|
|
63
|
+
expect(pos.player).to eq(:white)
|
|
64
|
+
expect(pos.castling).to eq(%w[K Q k q])
|
|
65
|
+
expect(pos.en_passant).to be_nil
|
|
66
|
+
expect(pos.halfmove).to eq(0)
|
|
67
|
+
expect(pos.fullmove).to eq(1)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe '#next_player' do
|
|
72
|
+
it 'toggles white to black and back' do
|
|
73
|
+
expect(PGN::Position.start.next_player).to eq(:black)
|
|
74
|
+
expect(PGN::Position.start.move('e4').next_player).to eq(:white)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe '#move' do
|
|
79
|
+
it 'returns a new PGN::Position and does not mutate the source' do
|
|
80
|
+
pos = PGN::Position.start
|
|
81
|
+
nxt = pos.move('e4')
|
|
82
|
+
expect(nxt).to be_a(PGN::Position)
|
|
83
|
+
expect(nxt).not_to be(pos)
|
|
84
|
+
expect(pos.board.at('e4')).to be_nil
|
|
85
|
+
expect(pos.player).to eq(:white)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'toggles the player to move' do
|
|
89
|
+
expect(PGN::Position.start.move('e4').player).to eq(:black)
|
|
90
|
+
expect(PGN::Position.start.move('e4').move('e5').player).to eq(:white)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'updates state after 1.e4' do
|
|
94
|
+
nxt = PGN::Position.start.move('e4')
|
|
95
|
+
expect(nxt.en_passant).to eq('e3')
|
|
96
|
+
expect(nxt.halfmove).to eq(0)
|
|
97
|
+
expect(nxt.fullmove).to eq(1)
|
|
98
|
+
expect(nxt.board.at('e4')).to eq('P')
|
|
99
|
+
expect(nxt.board.at('e2')).to be_nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'updates state after 1.e4 e5' do
|
|
103
|
+
nxt = PGN::Position.start.move('e4').move('e5')
|
|
104
|
+
expect(nxt.player).to eq(:white)
|
|
105
|
+
expect(nxt.en_passant).to eq('e6')
|
|
106
|
+
expect(nxt.fullmove).to eq(2)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'propagates castling restrictions' do
|
|
110
|
+
fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
|
|
111
|
+
nxt = PGN::FEN.new(fen).to_position.move('O-O')
|
|
112
|
+
expect(nxt.castling.sort).to eq(%w[k q])
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
describe '#to_fen' do
|
|
117
|
+
it 'round-trips the start position to FEN::INITIAL' do
|
|
118
|
+
expect(PGN::Position.start.to_fen.to_s).to eq(PGN::FEN::INITIAL)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'round-trips an arbitrary position through FEN' do
|
|
122
|
+
fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
|
|
123
|
+
parsed = PGN::FEN.new(fen).to_position
|
|
124
|
+
expect(parsed.to_fen.to_s).to eq(fen)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe PGN::Serializer do
|
|
4
|
+
describe '#to_s' do
|
|
5
|
+
it 'serializes a simple game with tags and result' do
|
|
6
|
+
game = PGN::Game.new(%w[e4 e5], { 'White' => 'A', 'Black' => 'B' }, '1-0')
|
|
7
|
+
expected = "[White \"A\"]\n[Black \"B\"]\n\n1. e4 e5 1-0\n"
|
|
8
|
+
expect(PGN::Serializer.new(game).to_s).to eq(expected)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'synthesizes a Result tag when there are no tags' do
|
|
12
|
+
game = PGN::Game.new(%w[e4 e5])
|
|
13
|
+
expected = "[Result \"*\"]\n\n1. e4 e5 *\n"
|
|
14
|
+
expect(PGN::Serializer.new(game).to_s).to eq(expected)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'serializes an empty game as just the result' do
|
|
18
|
+
game = PGN::Game.new([], nil, '*')
|
|
19
|
+
expected = "[Result \"*\"]\n\n*\n"
|
|
20
|
+
expect(PGN::Serializer.new(game).to_s).to eq(expected)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'emits * when there is no result' do
|
|
24
|
+
game = PGN::Game.new(%w[e4 e5], { 'White' => 'A' })
|
|
25
|
+
expect(PGN::Serializer.new(game).to_s).to end_with("1. e4 e5 *\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'escapes backslashes and quotes in tag values' do
|
|
29
|
+
game = PGN::Game.new(%w[e4], { 'White' => 'A "B" \\C' }, '1-0')
|
|
30
|
+
expect(PGN::Serializer.new(game).to_s).to start_with("[White \"A \\\"B\\\" \\\\C\"]\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'serializes castling as O-O / O-O-O' do
|
|
34
|
+
game = PGN::Game.new(%w[O-O O-O-O])
|
|
35
|
+
expect(PGN::Serializer.new(game).to_s).to eq("[Result \"*\"]\n\n1. O-O O-O-O *\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'emits a single annotation after the notation' do
|
|
39
|
+
moves = [PGN::MoveText.new('e4', ['$4'])]
|
|
40
|
+
game = PGN::Game.new(moves, { 'White' => 'A' }, '1-0')
|
|
41
|
+
expect(PGN::Serializer.new(game).to_s).to eq("[White \"A\"]\n\n1. e4 $4 1-0\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'emits symbolic annotations verbatim' do
|
|
45
|
+
moves = [PGN::MoveText.new('e4', ['??'])]
|
|
46
|
+
game = PGN::Game.new(moves, { 'White' => 'A' }, '1-0')
|
|
47
|
+
expect(PGN::Serializer.new(game).to_s).to eq("[White \"A\"]\n\n1. e4 ?? 1-0\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'emits multiple annotations in order' do
|
|
51
|
+
moves = [PGN::MoveText.new('d4'), PGN::MoveText.new('d5', ['$2', '$11'])]
|
|
52
|
+
game = PGN::Game.new(moves, { 'White' => 'A' }, '1/2-1/2')
|
|
53
|
+
expect(PGN::Serializer.new(game).to_s).to eq("[White \"A\"]\n\n1. d4 d5 $2 $11 1/2-1/2\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'wraps move comments in braces with spaces' do
|
|
57
|
+
moves = [PGN::MoveText.new('e4', nil, 'good move')]
|
|
58
|
+
game = PGN::Game.new(moves, { 'White' => 'A' }, '1-0')
|
|
59
|
+
expect(PGN::Serializer.new(game).to_s).to eq("[White \"A\"]\n\n1. e4 { good move } 1-0\n")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'reproduces the variations.pgn movetext shape, including 2... after variations' do
|
|
63
|
+
game = PGN.parse(File.read('./spec/pgn_files/variations.pgn')).first
|
|
64
|
+
expected = "[Black \"Petrov\"]\n[White \"Somebody\"]\n\n" \
|
|
65
|
+
"1. e4 e5 2. Nf3 { comment } " \
|
|
66
|
+
"(2. f4 exf4 { final variation }) " \
|
|
67
|
+
"(2. Nc3 { other } 2... d5 (2... f5) 3. exd5) " \
|
|
68
|
+
"2... Nf6 *\n"
|
|
69
|
+
expect(PGN::Serializer.new(game).to_s).to eq(expected)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'emits a game comment as the first movetext token' do
|
|
73
|
+
game = PGN::Game.new([], nil, '*', nil, 'game comment')
|
|
74
|
+
expect(PGN::Serializer.new(game).to_s).to eq("[Result \"*\"]\n\n{ game comment } *\n")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'numbers the first move 1... when starting from a FEN with black to move' do
|
|
78
|
+
fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1'
|
|
79
|
+
game = PGN::Game.new(%w[e5], { 'FEN' => fen }, '*')
|
|
80
|
+
expected = "[FEN \"#{fen}\"]\n\n1... e5 *\n"
|
|
81
|
+
expect(PGN::Serializer.new(game).to_s).to eq(expected)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'serializes -- moves verbatim and alternates color/fullmove' do
|
|
85
|
+
game = PGN::Game.new(%w[-- e4])
|
|
86
|
+
expect(PGN::Serializer.new(game).to_s).to eq("[Result \"*\"]\n\n1. -- e4 *\n")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/spec/spec_helper.rb
CHANGED