pgn2 2.0.0 → 2.0.2
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/release-gems.yml +5 -2
- data/CHANGELOG.md +101 -2
- data/README.md +27 -0
- data/Rakefile +18 -0
- data/TODO.md +25 -107
- data/docs/superpowers/plans/2026-08-15-game-tree-api-plan.md +1014 -0
- data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -0
- data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
- data/ext/pgn2_native/pgn2-bitboard/src/board.rs +2 -10
- data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +0 -2
- data/ext/pgn2_native/pgn2_native/src/lib.rs +5 -5
- data/lib/pgn/attack.rb +107 -0
- data/lib/pgn/epd.rb +54 -0
- data/lib/pgn/fen.rb +54 -44
- data/lib/pgn/game.rb +91 -7
- data/lib/pgn/move.rb +4 -4
- data/lib/pgn/node.rb +347 -0
- data/lib/pgn/notation.rb +8 -66
- data/lib/pgn/pgn_parser.rb +30 -31
- data/lib/pgn/pgn_parser.y +14 -15
- data/lib/pgn/position.rb +193 -0
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +3 -0
- data/spec/castling_normalization_spec.rb +39 -0
- data/spec/comment_round_trip_spec.rb +35 -0
- data/spec/epd_spec.rb +44 -0
- data/spec/fen_spec.rb +6 -0
- data/spec/game_history_spec.rb +46 -0
- data/spec/game_spec.rb +46 -0
- data/spec/movetext_clean_spec.rb +44 -0
- data/spec/node_spec.rb +222 -0
- data/spec/outcome_spec.rb +93 -0
- data/spec/parser_left_recursion_spec.rb +37 -0
- data/spec/position_attack_spec.rb +56 -0
- data/spec/position_legal_spec.rb +123 -0
- data/spec/spec_helper.rb +17 -0
- metadata +18 -3
- data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +0 -121
data/lib/pgn/notation.rb
CHANGED
|
@@ -145,8 +145,8 @@ module PGN
|
|
|
145
145
|
|
|
146
146
|
def suffix(from_idx, to_idx, piece, promotion, castling)
|
|
147
147
|
nb = apply_move(from_idx, to_idx, piece, promotion, castling)
|
|
148
|
-
opp_king = king_idx(nb, @enemy)
|
|
149
|
-
return '' unless opp_king && attacked?(nb, opp_king, @mover)
|
|
148
|
+
opp_king = PGN::Attack.king_idx(nb, @enemy)
|
|
149
|
+
return '' unless opp_king && PGN::Attack.attacked?(nb, opp_king, @mover)
|
|
150
150
|
|
|
151
151
|
result_pos = PGN::Position.new(
|
|
152
152
|
nb,
|
|
@@ -219,8 +219,8 @@ module PGN
|
|
|
219
219
|
nb.update_index(ep, nil) if ep
|
|
220
220
|
nb.update_index(from_idx, nil)
|
|
221
221
|
nb.update_index(to_idx, piece)
|
|
222
|
-
king = king_idx(nb, @mover)
|
|
223
|
-
king && !attacked?(nb, king, @enemy)
|
|
222
|
+
king = PGN::Attack.king_idx(nb, @mover)
|
|
223
|
+
king && !PGN::Attack.attacked?(nb, king, @enemy)
|
|
224
224
|
end
|
|
225
225
|
|
|
226
226
|
# Does `piece` at `from_idx` pseudo-legally reach `to_idx` on the board?
|
|
@@ -278,66 +278,8 @@ module PGN
|
|
|
278
278
|
|
|
279
279
|
# -- attack / legality helpers ----------------------------------------
|
|
280
280
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
(0...128).each do |idx|
|
|
284
|
-
next if idx.anybits?(0x88)
|
|
285
|
-
|
|
286
|
-
return idx if board.at_index(idx) == king
|
|
287
|
-
end
|
|
288
|
-
nil
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
# Is `target` attacked by `color`-colored pieces on `board`?
|
|
292
|
-
def attacked?(board, target, color)
|
|
293
|
-
pawn_attacked?(board, target, color) ||
|
|
294
|
-
knight_attacked?(board, target, color) ||
|
|
295
|
-
king_attacked?(board, target, color) ||
|
|
296
|
-
slider_attacked?(board, target, color)
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
def pawn_attacked?(board, target, color)
|
|
300
|
-
offs = color == 'w' ? [-15, -17] : [15, 17]
|
|
301
|
-
pawn = color == 'w' ? 'P' : 'p'
|
|
302
|
-
offs.any? do |off|
|
|
303
|
-
i = target + off
|
|
304
|
-
i.nobits?(0x88) && board.at_index(i) == pawn
|
|
305
|
-
end
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
def knight_attacked?(board, target, color)
|
|
309
|
-
knight = color == 'w' ? 'N' : 'n'
|
|
310
|
-
Board::KNIGHT_ATTACKS[target].any? { |i| board.at_index(i) == knight }
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
def king_attacked?(board, target, color)
|
|
314
|
-
king = color == 'w' ? 'K' : 'k'
|
|
315
|
-
Board::KING_ATTACKS[target].any? { |i| board.at_index(i) == king }
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
def slider_attacked?(board, target, color)
|
|
319
|
-
bishop = color == 'w' ? 'B' : 'b'
|
|
320
|
-
rook = color == 'w' ? 'R' : 'r'
|
|
321
|
-
queen = color == 'w' ? 'Q' : 'q'
|
|
322
|
-
ray_hit?(board, target, BISHOP_DIRS) { |p| p == bishop || p == queen } ||
|
|
323
|
-
ray_hit?(board, target, ROOK_DIRS) { |p| p == rook || p == queen }
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
def ray_hit?(board, target, dirs)
|
|
327
|
-
dirs.each do |off|
|
|
328
|
-
i = target + off
|
|
329
|
-
while i.nobits?(0x88)
|
|
330
|
-
piece = board.at_index(i)
|
|
331
|
-
if piece
|
|
332
|
-
return true if yield(piece)
|
|
333
|
-
|
|
334
|
-
break
|
|
335
|
-
end
|
|
336
|
-
i += off
|
|
337
|
-
end
|
|
338
|
-
end
|
|
339
|
-
false
|
|
340
|
-
end
|
|
281
|
+
# (Attack detection is delegated to {PGN::Attack}, the shared source of
|
|
282
|
+
# truth used by {PGN::Position#in_check?} and {PGN::Position#attackers}.)
|
|
341
283
|
|
|
342
284
|
# -- legal-move generation (for checkmate detection) ------------------
|
|
343
285
|
|
|
@@ -454,8 +396,8 @@ module PGN
|
|
|
454
396
|
nb.update_index(from_idx, nil)
|
|
455
397
|
nb.update_index(to_idx, place_piece(piece, promotion))
|
|
456
398
|
color = own?(piece, 'w') ? 'w' : 'b'
|
|
457
|
-
king = king_idx(nb, color)
|
|
458
|
-
king && !attacked?(nb, king, color == 'w' ? 'b' : 'w')
|
|
399
|
+
king = PGN::Attack.king_idx(nb, color)
|
|
400
|
+
king && !PGN::Attack.attacked?(nb, king, color == 'w' ? 'b' : 'w')
|
|
459
401
|
end
|
|
460
402
|
|
|
461
403
|
# The piece letter to place on the destination after a pseudo-move.
|
data/lib/pgn/pgn_parser.rb
CHANGED
|
@@ -8,7 +8,7 @@ require 'racc/parser.rb'
|
|
|
8
8
|
module PGN
|
|
9
9
|
class PgnParser < Racc::Parser
|
|
10
10
|
|
|
11
|
-
module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y',
|
|
11
|
+
module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 96)
|
|
12
12
|
|
|
13
13
|
def parse(input)
|
|
14
14
|
@lexer = PGN::Lexer.new(input)
|
|
@@ -71,37 +71,37 @@ racc_action_table = [
|
|
|
71
71
|
21, 25, 31 ]
|
|
72
72
|
|
|
73
73
|
racc_action_check = [
|
|
74
|
-
1, 28, 2, 28, 17, 28,
|
|
75
|
-
28,
|
|
76
|
-
|
|
74
|
+
1, 28, 2, 28, 17, 28, 4, 17, 6, 1,
|
|
75
|
+
28, 10, 10, 10, 24, 10, 11, 24, 15, 18,
|
|
76
|
+
19, 23, 29 ]
|
|
77
77
|
|
|
78
78
|
racc_action_pointer = [
|
|
79
|
-
nil, 0, 2, nil,
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
nil, 0, 2, nil, -3, nil, 0, nil, nil, nil,
|
|
80
|
+
8, 14, nil, nil, nil, 7, nil, 1, 9, 9,
|
|
81
|
+
nil, nil, nil, 15, 11, nil, nil, nil, -2, 16,
|
|
82
82
|
nil, nil, nil ]
|
|
83
83
|
|
|
84
84
|
racc_action_default = [
|
|
85
|
-
-1, -25, -25, -2, -8, -4, -25, 33, -3,
|
|
86
|
-
|
|
85
|
+
-1, -25, -25, -2, -8, -4, -25, 33, -3, -5,
|
|
86
|
+
-25, -25, -7, -9, -10, -11, -13, -14, -25, -12,
|
|
87
87
|
-22, -8, -15, -16, -17, -20, -6, -23, -25, -19,
|
|
88
88
|
-18, -21, -24 ]
|
|
89
89
|
|
|
90
90
|
racc_goto_table = [
|
|
91
|
-
|
|
92
|
-
22, nil, nil, nil, nil,
|
|
91
|
+
10, 24, 20, 1, 3, 5, 27, 29, 9, 4,
|
|
92
|
+
8, 19, 22, nil, nil, nil, nil, 28 ]
|
|
93
93
|
|
|
94
94
|
racc_goto_check = [
|
|
95
|
-
6, 11,
|
|
96
|
-
10, nil, nil, nil, nil,
|
|
95
|
+
6, 11, 12, 1, 2, 5, 12, 11, 5, 3,
|
|
96
|
+
4, 9, 10, nil, nil, nil, nil, 6 ]
|
|
97
97
|
|
|
98
98
|
racc_goto_pointer = [
|
|
99
|
-
nil,
|
|
100
|
-
-
|
|
99
|
+
nil, 3, 3, 8, 6, 4, -4, nil, nil, -4,
|
|
100
|
+
-5, -16, -13 ]
|
|
101
101
|
|
|
102
102
|
racc_goto_default = [
|
|
103
|
-
nil, nil, nil, nil, nil,
|
|
104
|
-
nil, nil,
|
|
103
|
+
nil, nil, nil, nil, nil, nil, nil, 13, 15, nil,
|
|
104
|
+
nil, nil, nil ]
|
|
105
105
|
|
|
106
106
|
racc_reduce_table = [
|
|
107
107
|
0, 0, :racc_error,
|
|
@@ -223,7 +223,7 @@ module_eval(<<'.,.,', 'pgn_parser.y', 15)
|
|
|
223
223
|
def _reduce_3(val, _values, result)
|
|
224
224
|
result = val[1].pop
|
|
225
225
|
result = {
|
|
226
|
-
tags: val[0],
|
|
226
|
+
tags: val[0].reverse_each.with_object({}) { |pair, h| h.merge!(pair) },
|
|
227
227
|
result: result,
|
|
228
228
|
moves: val[1],
|
|
229
229
|
pgn: nil,
|
|
@@ -236,18 +236,18 @@ module_eval(<<'.,.,', 'pgn_parser.y', 15)
|
|
|
236
236
|
|
|
237
237
|
module_eval(<<'.,.,', 'pgn_parser.y', 26)
|
|
238
238
|
def _reduce_4(val, _values, result)
|
|
239
|
-
result = val[0]
|
|
239
|
+
result = [val[0]]
|
|
240
240
|
result
|
|
241
241
|
end
|
|
242
242
|
.,.,
|
|
243
243
|
|
|
244
244
|
module_eval(<<'.,.,', 'pgn_parser.y', 29)
|
|
245
245
|
def _reduce_5(val, _values, result)
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
result = val[
|
|
246
|
+
# Left-recursive; the source-order array is reduced to the legacy
|
|
247
|
+
# tag semantics in {pgn_game} (reverse, then merge with
|
|
248
|
+
# first-occurrence-wins) so the whittle-order compatibility quirk is
|
|
249
|
+
# a single explicit line instead of implicit in recursion direction.
|
|
250
|
+
result = val[0] << val[1]
|
|
251
251
|
|
|
252
252
|
result
|
|
253
253
|
end
|
|
@@ -294,7 +294,7 @@ module_eval(<<'.,.,', 'pgn_parser.y', 50)
|
|
|
294
294
|
module_eval(<<'.,.,', 'pgn_parser.y', 54)
|
|
295
295
|
def _reduce_12(val, _values, result)
|
|
296
296
|
result = val[0]
|
|
297
|
-
result.variations = val[1]
|
|
297
|
+
result.variations = val[1].reverse
|
|
298
298
|
result
|
|
299
299
|
|
|
300
300
|
result
|
|
@@ -377,17 +377,16 @@ module_eval(<<'.,.,', 'pgn_parser.y', 82)
|
|
|
377
377
|
|
|
378
378
|
module_eval(<<'.,.,', 'pgn_parser.y', 85)
|
|
379
379
|
def _reduce_23(val, _values, result)
|
|
380
|
-
#
|
|
381
|
-
#
|
|
382
|
-
#
|
|
383
|
-
|
|
384
|
-
result = val[1] << val[0]
|
|
380
|
+
# Left-recursive; the legacy variation-order reversal is applied as
|
|
381
|
+
# a single explicit `.reverse` where the list is consumed in
|
|
382
|
+
# {element}, instead of being implicit in recursion direction.
|
|
383
|
+
result = val[0] << val[1]
|
|
385
384
|
|
|
386
385
|
result
|
|
387
386
|
end
|
|
388
387
|
.,.,
|
|
389
388
|
|
|
390
|
-
module_eval(<<'.,.,', 'pgn_parser.y',
|
|
389
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 92)
|
|
391
390
|
def _reduce_24(val, _values, result)
|
|
392
391
|
result = val[1]
|
|
393
392
|
result
|
data/lib/pgn/pgn_parser.y
CHANGED
|
@@ -15,7 +15,7 @@ rule
|
|
|
15
15
|
{
|
|
16
16
|
result = val[1].pop
|
|
17
17
|
result = {
|
|
18
|
-
tags: val[0],
|
|
18
|
+
tags: val[0].reverse_each.with_object({}) { |pair, h| h.merge!(pair) },
|
|
19
19
|
result: result,
|
|
20
20
|
moves: val[1],
|
|
21
21
|
pgn: nil,
|
|
@@ -24,14 +24,14 @@ rule
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
tag_section:
|
|
27
|
-
tag_pair { result = val[0] }
|
|
28
|
-
| tag_pair
|
|
27
|
+
tag_pair { result = [val[0]] }
|
|
28
|
+
| tag_section tag_pair
|
|
29
29
|
{
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
result = val[
|
|
30
|
+
# Left-recursive; the source-order array is reduced to the legacy
|
|
31
|
+
# tag semantics in {pgn_game} (reverse, then merge with
|
|
32
|
+
# first-occurrence-wins) so the whittle-order compatibility quirk is
|
|
33
|
+
# a single explicit line instead of implicit in recursion direction.
|
|
34
|
+
result = val[0] << val[1]
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
tag_pair:
|
|
@@ -53,7 +53,7 @@ rule
|
|
|
53
53
|
| san_move_annotated variation_list
|
|
54
54
|
{
|
|
55
55
|
result = val[0]
|
|
56
|
-
result.variations = val[1]
|
|
56
|
+
result.variations = val[1].reverse
|
|
57
57
|
result
|
|
58
58
|
}
|
|
59
59
|
| COMMENT
|
|
@@ -81,13 +81,12 @@ rule
|
|
|
81
81
|
|
|
82
82
|
variation_list:
|
|
83
83
|
variation { result = [val[0]] }
|
|
84
|
-
| variation
|
|
84
|
+
| variation_list variation
|
|
85
85
|
{
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
result = val[1] << val[0]
|
|
86
|
+
# Left-recursive; the legacy variation-order reversal is applied as
|
|
87
|
+
# a single explicit `.reverse` where the list is consumed in
|
|
88
|
+
# {element}, instead of being implicit in recursion direction.
|
|
89
|
+
result = val[0] << val[1]
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
variation:
|
data/lib/pgn/position.rb
CHANGED
|
@@ -124,10 +124,133 @@ module PGN
|
|
|
124
124
|
PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
# All legal moves from this position as sorted SAN strings, computed
|
|
128
|
+
# by delegating the native engine's UCI move list through
|
|
129
|
+
# {PGN::Notation.san}. Requires the compiled native extension; raises
|
|
130
|
+
# NameError if it is absent.
|
|
131
|
+
#
|
|
132
|
+
# @return [Array<String>] sorted lexicographically
|
|
133
|
+
#
|
|
134
|
+
def legal_moves_san
|
|
135
|
+
engine = PGN::Bitboard::Engine.new(to_fen.to_s)
|
|
136
|
+
engine.legal_moves.map { |uci| uci_to_san(uci) }.sort
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Whether +move+ is legal. Accepts SAN ("Nf3", "e4", "O-O", "a8=Q",
|
|
140
|
+
# "Qxf7#") or UCI ("g1f3", "e2e4", "e1g1", "a7a8q"). Requires the
|
|
141
|
+
# compiled native extension; raises NameError if it is absent.
|
|
142
|
+
#
|
|
143
|
+
# UCI is handed straight to the engine. SAN is resolved against the
|
|
144
|
+
# engine's legal move list: a SAN string is legal only when it points
|
|
145
|
+
# at exactly one legal move (so an ambiguous "Nd2" with two knights
|
|
146
|
+
# is rejected, while "Nbd2"/"Nfd2" are accepted). The engine remains
|
|
147
|
+
# the single source of truth for legality (king safety, pins, etc.).
|
|
148
|
+
#
|
|
149
|
+
# @param move [String] SAN or UCI
|
|
150
|
+
# @return [Boolean]
|
|
151
|
+
def legal?(move)
|
|
152
|
+
engine = PGN::Bitboard::Engine.new(to_fen.to_s)
|
|
153
|
+
return engine.legal?(move) if uci?(move)
|
|
154
|
+
|
|
155
|
+
parsed = PGN::Move.new(move, player)
|
|
156
|
+
return false if parsed.destination.nil? && parsed.castle.nil?
|
|
157
|
+
|
|
158
|
+
candidates = engine.legal_moves.select { |uci| matches_san?(uci, parsed) }
|
|
159
|
+
candidates.size == 1
|
|
160
|
+
rescue StandardError
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
127
164
|
def inspect
|
|
128
165
|
"\n#{board.inspect}"
|
|
129
166
|
end
|
|
130
167
|
|
|
168
|
+
# The color string for the side to move ('w'/'b').
|
|
169
|
+
def mover_color
|
|
170
|
+
player == :white ? 'w' : 'b'
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The color string for the opponent of the side to move ('w'/'b').
|
|
174
|
+
def opponent_color
|
|
175
|
+
player == :white ? 'b' : 'w'
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Whether the side to move's king is currently in check.
|
|
179
|
+
#
|
|
180
|
+
# @return [Boolean]
|
|
181
|
+
def in_check?
|
|
182
|
+
return @in_check if defined?(@in_check)
|
|
183
|
+
|
|
184
|
+
king = PGN::Attack.king_idx(board, mover_color)
|
|
185
|
+
@in_check = !king.nil? && PGN::Attack.attacked?(board, king, opponent_color)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The algebraic squares of every piece of the given color that attacks
|
|
189
|
+
# +square+ (algebraic, e.g. "e4"). Defaults to the side to move's
|
|
190
|
+
# opponent, which is useful for check detection.
|
|
191
|
+
#
|
|
192
|
+
# @param square [String] e.g. "e4"
|
|
193
|
+
# @param color [String, nil] 'w' or 'b'; defaults to the opponent color
|
|
194
|
+
# @return [Array<String>] squares attacking +square+
|
|
195
|
+
def attackers(square, color = opponent_color)
|
|
196
|
+
PGN::Attack.attackers(board, board.index_of(square), color)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Whether the side to move has been checkmated (in check and no legal
|
|
200
|
+
# move). Requires the native extension for legal-move enumeration.
|
|
201
|
+
#
|
|
202
|
+
# @return [Boolean]
|
|
203
|
+
def checkmate?
|
|
204
|
+
in_check? && legal_moves.empty?
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Whether the side to move has been stalemated (not in check and no
|
|
208
|
+
# legal move). Requires the native extension.
|
|
209
|
+
#
|
|
210
|
+
# @return [Boolean]
|
|
211
|
+
def stalemate?
|
|
212
|
+
!in_check? && legal_moves.empty?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Whether the position has insufficient material to mate. Covers K vs K,
|
|
216
|
+
# K + one minor vs K, and same-colored bishops only.
|
|
217
|
+
#
|
|
218
|
+
# @return [Boolean]
|
|
219
|
+
def insufficient_material?
|
|
220
|
+
non_king = (0...128).each_with_object([]) do |idx, acc|
|
|
221
|
+
next if idx.anybits?(0x88)
|
|
222
|
+
|
|
223
|
+
piece = board.at_index(idx)
|
|
224
|
+
acc << [piece, idx] if piece && piece.upcase != 'K'
|
|
225
|
+
end
|
|
226
|
+
return true if non_king.empty?
|
|
227
|
+
return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase)
|
|
228
|
+
|
|
229
|
+
bishops_same_color?(non_king)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Whether the 50-move rule applies (100 halfmoves since the last pawn
|
|
233
|
+
# move or capture).
|
|
234
|
+
#
|
|
235
|
+
# @return [Boolean]
|
|
236
|
+
def fifty_move?
|
|
237
|
+
halfmove >= 100
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The terminal status of this position: :checkmate, :stalemate, or :draw
|
|
241
|
+
# (insufficient material or 50-move rule). nil if the position is still
|
|
242
|
+
# in progress. Threefold repetition requires game history and is
|
|
243
|
+
# answered by {PGN::Game#outcome}.
|
|
244
|
+
#
|
|
245
|
+
# @return [Symbol, nil]
|
|
246
|
+
def outcome
|
|
247
|
+
return :checkmate if checkmate?
|
|
248
|
+
return :stalemate if stalemate?
|
|
249
|
+
return :draw if insufficient_material? || fifty_move?
|
|
250
|
+
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
131
254
|
# @return [PGN::FEN] a {PGN::FEN} object representing the current position
|
|
132
255
|
#
|
|
133
256
|
def to_fen
|
|
@@ -167,5 +290,75 @@ module PGN
|
|
|
167
290
|
def zobrist
|
|
168
291
|
@zobrist ||= Zobrist.seed(board, player, castling, en_passant)
|
|
169
292
|
end
|
|
293
|
+
|
|
294
|
+
private
|
|
295
|
+
|
|
296
|
+
# True when +move+ looks like a UCI coordinate string ("e2e4",
|
|
297
|
+
# "e1g1", "a7a8q"), so it can be handed straight to the engine.
|
|
298
|
+
def uci?(move)
|
|
299
|
+
move.is_a?(String) && move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Convert a UCI string to SAN using the current position, for
|
|
303
|
+
# {#legal_moves_san}. Promotion (if present) is passed as the letter.
|
|
304
|
+
def uci_to_san(uci)
|
|
305
|
+
from = uci[0, 2]
|
|
306
|
+
to = uci[2, 2]
|
|
307
|
+
promo = uci[4]
|
|
308
|
+
PGN::Notation.san(self, from, to, promo)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Map a castling side letter from {PGN::Move#castle} to the king's
|
|
312
|
+
# from/to UCI squares for the side to move.
|
|
313
|
+
CASTLE_UCI = {
|
|
314
|
+
'K' => 'e1g1',
|
|
315
|
+
'Q' => 'e1c1',
|
|
316
|
+
'k' => 'e8g8',
|
|
317
|
+
'q' => 'e8c8'
|
|
318
|
+
}.freeze
|
|
319
|
+
private_constant :CASTLE_UCI
|
|
320
|
+
|
|
321
|
+
# Does the legal UCI move +uci+ match the parsed SAN +move+? A move
|
|
322
|
+
# matches when destination, piece, promotion, and castling all agree,
|
|
323
|
+
# and the origin square satisfies +move+'s disambiguation (if any).
|
|
324
|
+
def matches_san?(uci, move)
|
|
325
|
+
return uci == CASTLE_UCI[move.castle] if move.castle
|
|
326
|
+
|
|
327
|
+
to = uci[2, 2]
|
|
328
|
+
return false if move.destination != to
|
|
329
|
+
|
|
330
|
+
from_idx = board.index_of(uci[0, 2])
|
|
331
|
+
return false if board.at_index(from_idx) != move.piece
|
|
332
|
+
|
|
333
|
+
promo = uci[4]
|
|
334
|
+
return false if move.promotion&.downcase != promo
|
|
335
|
+
|
|
336
|
+
disambiguation_matches?(move.disambiguation, from_idx)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Whether the disambiguation string from SAN (a file, a rank, or a full
|
|
340
|
+
# square) describes the origin square at +from_idx+. nil disambiguation
|
|
341
|
+
# matches any origin (ambiguity is handled by the caller counting matches).
|
|
342
|
+
def disambiguation_matches?(disambiguation, from_idx)
|
|
343
|
+
return true if disambiguation.nil? || disambiguation.empty?
|
|
344
|
+
|
|
345
|
+
file = Board::INDEX_TO_FILE[from_idx & 0x0F]
|
|
346
|
+
rank = Board::INDEX_TO_RANK[from_idx >> 4]
|
|
347
|
+
case disambiguation
|
|
348
|
+
when /\A[a-h]\z/ then file == disambiguation
|
|
349
|
+
when /\A[1-8]\z/ then rank == disambiguation
|
|
350
|
+
else file + rank == disambiguation
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Whether +non_king+ is all bishops on squares of the same color (a known
|
|
355
|
+
# insufficient-material draw, e.g. KB vs KB with same-colored bishops).
|
|
356
|
+
def bishops_same_color?(non_king)
|
|
357
|
+
return false unless non_king.all? { |piece, _| piece.upcase == 'B' }
|
|
358
|
+
return false if non_king.size > 2
|
|
359
|
+
|
|
360
|
+
colors = non_king.map { |_piece, idx| ((idx & 0x0F) + (idx >> 4)) % 2 }
|
|
361
|
+
colors.uniq.size == 1
|
|
362
|
+
end
|
|
170
363
|
end
|
|
171
364
|
end
|
data/lib/pgn/version.rb
CHANGED
data/lib/pgn.rb
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
require 'pgn/board'
|
|
2
2
|
require 'pgn/fen'
|
|
3
|
+
require 'pgn/epd'
|
|
3
4
|
require 'pgn/game'
|
|
5
|
+
require 'pgn/node'
|
|
4
6
|
require 'pgn/move'
|
|
5
7
|
require 'pgn/move_calculator'
|
|
6
8
|
require 'pgn/lexer'
|
|
7
9
|
require 'pgn/notation'
|
|
10
|
+
require 'pgn/attack'
|
|
8
11
|
require 'pgn/zobrist'
|
|
9
12
|
require 'pgn/pgn_parser'
|
|
10
13
|
require 'pgn/parser'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe 'UCI-style castling normalization' do
|
|
6
|
+
let(:fen) { 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1' }
|
|
7
|
+
let(:pos) { PGN::FEN.new(fen).to_position }
|
|
8
|
+
|
|
9
|
+
it 'parses 0-0 / 0-0-0 the same as O-O / O-O-O in PGN::Move' do
|
|
10
|
+
expect(PGN::Move.new('0-0', :white).castle).to eq('K')
|
|
11
|
+
expect(PGN::Move.new('0-0-0', :white).castle).to eq('Q')
|
|
12
|
+
expect(PGN::Move.new('0-0', :black).castle).to eq('k')
|
|
13
|
+
expect(PGN::Move.new('0-0-0', :black).castle).to eq('q')
|
|
14
|
+
expect(PGN::Move.new('0-0', :white).piece).to be_nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'normalizes parsed 0-0 movetext to O-O on a game' do
|
|
18
|
+
game = PGN::Game.new(%w[0-0])
|
|
19
|
+
expect(game.moves.first.notation).to eq('O-O')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'Notation.san produces O-O / O-O-O for the king two-file moves' do
|
|
23
|
+
expect(PGN::Notation.san(pos, 'e1', 'g1')).to eq('O-O')
|
|
24
|
+
expect(PGN::Notation.san(pos, 'e1', 'c1')).to eq('O-O-O')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'Position#legal? accepts both O-O and UCI e1g1 castling' do
|
|
28
|
+
expect(pos.legal?('O-O')).to be(true)
|
|
29
|
+
expect(pos.legal?('O-O-O')).to be(true)
|
|
30
|
+
expect(pos.legal?('e1g1')).to be(true)
|
|
31
|
+
expect(pos.legal?('e1c1')).to be(true)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'serializes castling as O-O, never 0-0' do
|
|
35
|
+
game = PGN::Game.new(%w[e4 e5 O-O])
|
|
36
|
+
expect(game.to_pgn).to include('O-O')
|
|
37
|
+
expect(game.to_pgn).not_to include('0-0')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe 'comment round-tripping' do
|
|
6
|
+
it 'round-trips nested braces through parse -> serialize -> parse' do
|
|
7
|
+
input = +"[White \"A\"]\n\n1. e4 {this {is a} nested {comment}} *\n"
|
|
8
|
+
g1 = PGN.parse(input, Encoding::UTF_8).first
|
|
9
|
+
g2 = PGN.parse(g1.to_pgn, Encoding::UTF_8).first
|
|
10
|
+
expect(g2.moves.first.comment).to eq(g1.moves.first.comment)
|
|
11
|
+
expect(g2.moves.first.comment).to eq('this {is a} nested {comment}')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'is stable: serialize -> parse -> serialize is idempotent' do
|
|
15
|
+
input = +"[White \"A\"]\n\n1. e4 {this {is a} nested {comment}} *\n"
|
|
16
|
+
once = PGN.parse(input, Encoding::UTF_8).first.to_pgn
|
|
17
|
+
twice = PGN.parse(once, Encoding::UTF_8).first.to_pgn
|
|
18
|
+
expect(twice).to eq(once)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'round-trips escaped braces and backslashes' do
|
|
22
|
+
input = +"[White \"A\"]\n\n1. e4 {a \\{literal\\} back\\\\slash} *\n"
|
|
23
|
+
g = PGN.parse(input, Encoding::UTF_8).first
|
|
24
|
+
expect(g.moves.first.comment).to eq('a {literal} back\\slash')
|
|
25
|
+
reparsed = PGN.parse(g.to_pgn, Encoding::UTF_8).first
|
|
26
|
+
expect(reparsed.moves.first.comment).to eq(g.moves.first.comment)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'round-trips the nested_comments fixture comment body' do
|
|
30
|
+
input = File.binread('spec/pgn_files/nested_comments.pgn')
|
|
31
|
+
g = PGN.parse(input, Encoding::UTF_8).first
|
|
32
|
+
reparsed = PGN.parse(g.to_pgn, Encoding::UTF_8).first
|
|
33
|
+
expect(reparsed.moves.map(&:comment)).to eq(g.moves.map(&:comment))
|
|
34
|
+
end
|
|
35
|
+
end
|
data/spec/epd_spec.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PGN::EPD do
|
|
6
|
+
let(:start_epd) { 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -' }
|
|
7
|
+
|
|
8
|
+
it 'parses the placement/side/castling/ep fields' do
|
|
9
|
+
epd = PGN::EPD.new(start_epd)
|
|
10
|
+
pos = epd.to_position
|
|
11
|
+
expect(pos.player).to eq(:white)
|
|
12
|
+
expect(pos.castling).to eq(%w[K Q k q])
|
|
13
|
+
expect(pos.en_passant).to be_nil
|
|
14
|
+
expect(pos.halfmove).to eq(0)
|
|
15
|
+
expect(pos.fullmove).to eq(1)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'round-trips a simple position' do
|
|
19
|
+
expect(PGN::EPD.new(start_epd).to_s).to eq(start_epd)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'preserves trailing operation fields verbatim' do
|
|
23
|
+
epd = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - bm e4; id "start";'
|
|
24
|
+
expect(PGN::EPD.new(epd).to_s).to eq(epd)
|
|
25
|
+
expect(PGN::EPD.new(epd).ops).to eq('bm e4; id "start";')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'normalizes nil castling/ep to "-"' do
|
|
29
|
+
epd = PGN::EPD.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w')
|
|
30
|
+
expect(epd.castling).to eq('-')
|
|
31
|
+
expect(epd.en_passant).to eq('-')
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
RSpec.describe PGN::FEN, '#to_epd' do
|
|
36
|
+
it 'drops the move counters from the start position' do
|
|
37
|
+
expect(PGN::FEN.start.to_epd).to eq('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'preserves castling and en passant' do
|
|
41
|
+
fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
|
|
42
|
+
expect(PGN::FEN.new(fen).to_epd).to eq('rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6')
|
|
43
|
+
end
|
|
44
|
+
end
|
data/spec/fen_spec.rb
CHANGED
|
@@ -47,6 +47,12 @@ describe PGN::FEN do
|
|
|
47
47
|
next_pos = pos.move('d6')
|
|
48
48
|
next_pos.to_fen.en_passant.should == '-'
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
it 'preserves the en passant square through FEN#to_position' do
|
|
52
|
+
fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
|
|
53
|
+
expect(PGN::FEN.new(fen).to_position.en_passant).to eq('d6')
|
|
54
|
+
expect(PGN::FEN.new(fen).to_position.to_fen.to_s).to eq(fen)
|
|
55
|
+
end
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
describe 'halfmove counter' do
|