pgn2 0.4.0 → 1.1.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/.github/workflows/release.yml +103 -0
- data/.gitignore +2 -1
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +84 -0
- data/README.md +122 -4
- data/Rakefile +20 -0
- data/TODO.md +9 -0
- data/bench/.keep +0 -0
- data/bench/IMPROVEMENTS.md +143 -0
- data/bench/baseline_moves.pre-optimization.txt +22 -0
- data/bench/baseline_moves.pre-quickwins.txt +23 -0
- data/bench/baseline_moves.txt +22 -0
- data/bench/baseline_parse.pre-optimization.txt +25 -0
- data/bench/baseline_parse.pre-quickwins.txt +26 -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/docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md +227 -0
- data/lib/pgn/board.rb +33 -15
- data/lib/pgn/fen.rb +16 -8
- data/lib/pgn/game.rb +23 -3
- data/lib/pgn/lexer.rb +223 -0
- data/lib/pgn/move.rb +12 -5
- data/lib/pgn/move_calculator.rb +27 -21
- data/lib/pgn/parser.rb +13 -203
- data/lib/pgn/pgn_parser.rb +393 -0
- data/lib/pgn/pgn_parser.y +140 -0
- data/lib/pgn/position.rb +3 -2
- 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 +6 -23
- 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 +103 -15
data/lib/pgn/move_calculator.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module PGN
|
|
2
4
|
# {PGN::MoveCalculator} is responsible for computing all of the ways that a
|
|
3
5
|
# specific move changes the current position. This includes which squares on
|
|
@@ -80,9 +82,12 @@ module PGN
|
|
|
80
82
|
}
|
|
81
83
|
}.freeze
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
# Frozen rook-origin -> castling-restriction lookup, shared by both
|
|
86
|
+
# white ('R') and black ('r') since their rook origins (a1/h1, a8/h8)
|
|
87
|
+
# are distinct keys. Replaces a per-call hash literal.
|
|
88
|
+
ROOK_RESTRICTIONS = { 'a1' => 'Q', 'h1' => 'K', 'a8' => 'q', 'h8' => 'k' }.freeze
|
|
89
|
+
|
|
90
|
+
attr_accessor :board, :move, :origin
|
|
86
91
|
|
|
87
92
|
# @param board [PGN::Board] the current board
|
|
88
93
|
# @param move [PGN::Move] the current move
|
|
@@ -114,9 +119,9 @@ module PGN
|
|
|
114
119
|
when 'k'
|
|
115
120
|
restrict += %w[k q]
|
|
116
121
|
when 'R'
|
|
117
|
-
restrict <<
|
|
122
|
+
restrict << ROOK_RESTRICTIONS[origin]
|
|
118
123
|
when 'r'
|
|
119
|
-
restrict <<
|
|
124
|
+
restrict << ROOK_RESTRICTIONS[origin]
|
|
120
125
|
end
|
|
121
126
|
|
|
122
127
|
# when castling occurs
|
|
@@ -129,7 +134,7 @@ module PGN
|
|
|
129
134
|
restrict << 'K' if move.destination == 'h1'
|
|
130
135
|
restrict << 'k' if move.destination == 'h8'
|
|
131
136
|
|
|
132
|
-
restrict.compact.uniq
|
|
137
|
+
restrict.empty? ? restrict : restrict.compact.uniq
|
|
133
138
|
end
|
|
134
139
|
|
|
135
140
|
# @return [Boolean] whether to increment the halfmove clock
|
|
@@ -149,12 +154,12 @@ module PGN
|
|
|
149
154
|
def en_passant_square
|
|
150
155
|
return nil if move.castle
|
|
151
156
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
return unless move.pawn? && (origin[1].to_i - move.destination[1].to_i).abs == 2
|
|
158
|
+
|
|
159
|
+
if move.white?
|
|
160
|
+
"#{origin[0]}3"
|
|
161
|
+
else
|
|
162
|
+
"#{origin[0]}6"
|
|
158
163
|
end
|
|
159
164
|
end
|
|
160
165
|
|
|
@@ -260,7 +265,7 @@ end
|
|
|
260
265
|
possibilities.select { |p| board.position_for(p).match(move.disambiguation) }
|
|
261
266
|
else
|
|
262
267
|
possibilities
|
|
263
|
-
end
|
|
268
|
+
end
|
|
264
269
|
end
|
|
265
270
|
|
|
266
271
|
# A pawn can't move two spaces if there is a pawn in front of it.
|
|
@@ -270,17 +275,19 @@ end
|
|
|
270
275
|
possibilities.reject { |p| board.position_for(p).match(/2|7/) }
|
|
271
276
|
else
|
|
272
277
|
possibilities
|
|
273
|
-
end
|
|
278
|
+
end
|
|
274
279
|
end
|
|
275
280
|
|
|
276
281
|
# A piece can't move if it would result in a discovered check.
|
|
277
282
|
#
|
|
278
283
|
def disambiguate_discovered_check(possibilities)
|
|
284
|
+
king_pos = king_position
|
|
285
|
+
|
|
279
286
|
DIRECTIONS.each do |attacking_piece, directions|
|
|
280
287
|
attacking_piece = attacking_piece.upcase if move.black?
|
|
281
288
|
|
|
282
289
|
directions.each do |dir|
|
|
283
|
-
piece, square = first_piece(
|
|
290
|
+
piece, square = first_piece(king_pos, dir)
|
|
284
291
|
next unless piece == move.piece && possibilities.include?(square)
|
|
285
292
|
|
|
286
293
|
piece, = first_piece(square, dir)
|
|
@@ -298,7 +305,7 @@ end
|
|
|
298
305
|
piece = nil
|
|
299
306
|
|
|
300
307
|
while valid_square?(file += i, rank += j)
|
|
301
|
-
break if piece = board.at(file, rank)
|
|
308
|
+
break if (piece = board.at(file, rank))
|
|
302
309
|
end
|
|
303
310
|
|
|
304
311
|
[piece, [file, rank]]
|
|
@@ -316,22 +323,21 @@ end
|
|
|
316
323
|
def king_position
|
|
317
324
|
king = move.white? ? 'K' : 'k'
|
|
318
325
|
|
|
319
|
-
coords = nil
|
|
320
326
|
0.upto(7) do |file|
|
|
321
327
|
0.upto(7) do |rank|
|
|
322
|
-
|
|
328
|
+
return [file, rank] if board.at(file, rank) == king
|
|
323
329
|
end
|
|
324
330
|
end
|
|
325
331
|
|
|
326
|
-
|
|
332
|
+
nil
|
|
327
333
|
end
|
|
328
334
|
|
|
329
335
|
def valid_square?(file, rank)
|
|
330
|
-
(0..7)
|
|
336
|
+
(0..7).include?(file) && (0..7).include?(rank)
|
|
331
337
|
end
|
|
332
338
|
|
|
333
339
|
def destination_coords
|
|
334
|
-
board.coordinates_for(move.destination)
|
|
340
|
+
@dest_coords ||= board.coordinates_for(move.destination)
|
|
335
341
|
end
|
|
336
342
|
end
|
|
337
343
|
end
|
data/lib/pgn/parser.rb
CHANGED
|
@@ -1,208 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
def lex(input)
|
|
9
|
-
line = 1
|
|
10
|
-
offset = 0
|
|
11
|
-
ending = input.length
|
|
12
|
-
@@pgn ||= ''
|
|
13
|
-
@@game_comment ||= nil
|
|
14
|
-
|
|
15
|
-
until offset == ending
|
|
16
|
-
next_token(input, offset, line).tap do |token|
|
|
17
|
-
if !token.nil?
|
|
18
|
-
token[:offset] = offset
|
|
19
|
-
line, token[:line] = token[:line], line
|
|
20
|
-
yield token unless token[:discarded]
|
|
21
|
-
@@pgn += token[:value]
|
|
22
|
-
offset += token[:value].length
|
|
23
|
-
else
|
|
24
|
-
raise Whittle::UnconsumedInputError,
|
|
25
|
-
"Unmatched input #{input[offset..-1].inspect} on line #{line}"
|
|
26
|
-
# offset += 1
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
yield ({ name: :$end, line: line, value: nil, offset: offset })
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
rule(wsp: /\s+/).skip!
|
|
35
|
-
rule(
|
|
36
|
-
pgn_comment: /\A% .*/x
|
|
37
|
-
).skip!
|
|
38
|
-
|
|
39
|
-
rule('[')
|
|
40
|
-
rule(']')
|
|
41
|
-
rule('(')
|
|
42
|
-
rule(')')
|
|
43
|
-
|
|
44
|
-
start(:pgn_database)
|
|
45
|
-
|
|
46
|
-
rule(:pgn_database) do |r|
|
|
47
|
-
r[].as { [] }
|
|
48
|
-
r[:pgn_comment, :pgn_database].as { |_title, db| db }
|
|
49
|
-
r[:pgn_database, :pgn_game].as { |database, game| database << game }
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
rule(:pgn_game) do |r|
|
|
53
|
-
r[:tag_section, :movetext_section].as do |tags, moves|
|
|
54
|
-
old_pgn = @@pgn
|
|
55
|
-
@@pgn = ''
|
|
56
|
-
comment = @@game_comment
|
|
57
|
-
@@game_comment = nil
|
|
58
|
-
{ tags: tags, result: moves.pop, moves: moves, pgn: old_pgn, comment: comment }
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
rule(:tag_section) do |r|
|
|
63
|
-
r[:tag_pair, :tag_section].as { |pair, section| section.merge(pair) }
|
|
64
|
-
r[:tag_pair]
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
rule(:tag_pair) do |r|
|
|
68
|
-
r['[', :tag_name, :tag_value, ']'].as { |_, a, b, _| { a => b } }
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
rule(:tag_value) do |r|
|
|
72
|
-
r[:string].as { |value| value[1...-1] }
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
rule(:movetext_section) do |r|
|
|
76
|
-
r[:element_sequence, :game_termination].as { |a, b| a << b }
|
|
77
|
-
end
|
|
3
|
+
require 'pgn/pgn_parser'
|
|
78
4
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
move.variations = variations
|
|
91
|
-
move
|
|
92
|
-
end
|
|
93
|
-
r[:comment].as { |c| @@game_comment = c; nil }
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
rule(:san_move_annotated) do |r|
|
|
97
|
-
r[:san_move].as { |move| MoveText.new(move) }
|
|
98
|
-
r[:san_move, :comment].as do |move, comment|
|
|
99
|
-
MoveText.new(move, nil, comment)
|
|
100
|
-
end
|
|
101
|
-
r[:san_move, :annotation_list].as do |move, annotation|
|
|
102
|
-
MoveText.new(move, annotation)
|
|
103
|
-
end
|
|
104
|
-
r[:san_move, :annotation_list, :comment].as do |move, annotation, comment|
|
|
105
|
-
MoveText.new(move, annotation, comment)
|
|
106
|
-
end
|
|
107
|
-
r[:san_move, :comment, :annotation_list].as do |move, comment, annotation|
|
|
108
|
-
MoveText.new(move, annotation, comment)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
rule(:annotation_list) do |r|
|
|
113
|
-
r[:annotation_list, :numeric_annotation_glyph].as do |sequence, element|
|
|
114
|
-
element.nil? ? sequence : sequence << element
|
|
115
|
-
end
|
|
116
|
-
r[:numeric_annotation_glyph].as { |v| [v] }
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
rule(:variation_list) do |r|
|
|
120
|
-
r[:variation, :variation_list].as do |variation, sequence|
|
|
121
|
-
sequence << variation
|
|
122
|
-
end
|
|
123
|
-
r[:variation].as { |v| [v] }
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
rule(:variation) do |r|
|
|
127
|
-
r['(', :element_sequence, ')'].as { |_, sequence, _| sequence }
|
|
5
|
+
module PGN
|
|
6
|
+
# {PGN::Parser} is the public entry point for parsing PGN text into a list
|
|
7
|
+
# of game hashes. It delegates to the stdlib Racc + StringScanner parser
|
|
8
|
+
# {PGN::PgnParser}.
|
|
9
|
+
class Parser
|
|
10
|
+
# @param input [String] the raw PGN text (already force-encoded by the
|
|
11
|
+
# caller, e.g. via {PGN.parse}).
|
|
12
|
+
# @return [Array<Hash>] one Hash per game, with keys +:tags+, +:result+,
|
|
13
|
+
# +:moves+, +:pgn+, +:comment+.
|
|
14
|
+
def parse(input)
|
|
15
|
+
PGN::PgnParser.new.parse(input)
|
|
128
16
|
end
|
|
129
|
-
|
|
130
|
-
rule(
|
|
131
|
-
string: /
|
|
132
|
-
" # beginning of string
|
|
133
|
-
(
|
|
134
|
-
[[:print:]&&[^\\"]] | # printing characters except quote and backslash
|
|
135
|
-
\\\\ | # escaped backslashes
|
|
136
|
-
\\" # escaped quotation marks
|
|
137
|
-
)* # zero or more of the above
|
|
138
|
-
" # end of string
|
|
139
|
-
/x
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
rule(
|
|
143
|
-
comment: /
|
|
144
|
-
(
|
|
145
|
-
\{ # beginning of comment
|
|
146
|
-
(
|
|
147
|
-
[[:print:]&&[^\\\{\}]] | # printing characters except brace and backslash
|
|
148
|
-
\n |
|
|
149
|
-
\\\\ | # escaped backslashes
|
|
150
|
-
\\\{|\\\} | # escaped braces
|
|
151
|
-
\n | # newlines
|
|
152
|
-
\g<1> # recursive
|
|
153
|
-
)* # zero or more of the above
|
|
154
|
-
\} # end of comment
|
|
155
|
-
)
|
|
156
|
-
/x
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
rule(
|
|
160
|
-
game_termination: %r{
|
|
161
|
-
1-0 | # white wins
|
|
162
|
-
0-1 | # black wins
|
|
163
|
-
1\/2-1\/2 | # draw
|
|
164
|
-
\* # ?
|
|
165
|
-
}x
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
rule(
|
|
169
|
-
move_number_indication: /
|
|
170
|
-
[[:digit:]]+\.* # one or more digits followed by zero or more periods
|
|
171
|
-
/x
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
rule(
|
|
175
|
-
san_move: %r{
|
|
176
|
-
(
|
|
177
|
-
-- | # "don't care" move (used in variations)
|
|
178
|
-
[O0](-[O0]){1,2} | # castling (O-O, O-O-O)
|
|
179
|
-
[a-h][1-8] | # pawn moves (e4, d7)
|
|
180
|
-
[BKNQR][a-h1-8]?x?[a-h][1-8] | # major piece moves w/ optional specifier
|
|
181
|
-
# and capture
|
|
182
|
-
# (Bd2, N4c3, Raxc1)
|
|
183
|
-
[a-h][1-8]?x[a-h][1-8] # pawn captures
|
|
184
|
-
)
|
|
185
|
-
(
|
|
186
|
-
=[BNQR] # optional promotion (d8=Q)
|
|
187
|
-
)?
|
|
188
|
-
(
|
|
189
|
-
\+ | # check (g5+)
|
|
190
|
-
\# # checkmate (Qe7#)
|
|
191
|
-
)?
|
|
192
|
-
}x
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
rule(
|
|
196
|
-
tag_name: /
|
|
197
|
-
[A-Za-z0-9_]+ # letters, digits and underscores only
|
|
198
|
-
/x
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
rule(
|
|
202
|
-
numeric_annotation_glyph: /
|
|
203
|
-
\$\d+ | # dollar sign followed by an integer from 0 to 255
|
|
204
|
-
[\?!][\?!]? # support the most used annotations directly
|
|
205
|
-
/x
|
|
206
|
-
)
|
|
207
17
|
end
|
|
208
18
|
end
|