pgn2 1.5.0 → 2.0.1
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 +34 -3
- data/.github/workflows/native.yml +32 -0
- data/.github/workflows/publish.yml +44 -2
- data/.github/workflows/release-gems.yml +43 -0
- data/.github/workflows/release.yml +52 -5
- data/.gitignore +9 -1
- data/.rubocop.yml +46 -6
- data/CHANGELOG.md +215 -0
- data/Gemfile +3 -0
- data/NOTICE.md +21 -0
- data/README.md +134 -5
- data/Rakefile +53 -10
- data/TODO.md +44 -62
- data/bench/baseline_moves.txt +38 -4
- data/bench/baseline_parse.txt +4 -4
- data/bench/cross_check.rb +61 -0
- data/bench/legal_moves.rb +38 -0
- data/bench/perft.rb +32 -0
- data/bench/profile_moves.rb +93 -0
- data/docs/superpowers/plans/2026-08-13-attack-masks-plan.md +51 -0
- data/docs/superpowers/plans/2026-08-13-perf-internals-plan.md +883 -0
- data/docs/superpowers/plans/2026-08-13-rust-bitboard-perft-plan.md +2442 -0
- data/docs/superpowers/plans/2026-08-14-chessie-migration.md +722 -0
- data/docs/superpowers/plans/2026-08-14-rust-integration-plan.md +444 -0
- 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-13-attack-masks-design.md +57 -0
- data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
- data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
- data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
- data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
- data/ext/pgn2_native/Cargo.lock +321 -0
- data/ext/pgn2_native/Cargo.toml +19 -0
- data/ext/pgn2_native/extconf.rb +8 -0
- data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
- data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
- data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
- data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
- data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
- data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
- data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
- data/lib/pgn/attack.rb +97 -0
- data/lib/pgn/bitboard.rb +13 -0
- data/lib/pgn/board.rb +64 -0
- data/lib/pgn/epd.rb +81 -0
- data/lib/pgn/fen.rb +42 -46
- data/lib/pgn/game.rb +104 -16
- data/lib/pgn/move.rb +20 -16
- data/lib/pgn/move_calculator.rb +13 -1
- data/lib/pgn/node.rb +372 -0
- data/lib/pgn/notation.rb +26 -89
- data/lib/pgn/pgn_parser.rb +30 -31
- data/lib/pgn/pgn_parser.y +14 -15
- data/lib/pgn/position.rb +251 -19
- data/lib/pgn/serializer.rb +13 -18
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn/zobrist.rb +53 -0
- data/lib/pgn.rb +5 -0
- data/pgn2.gemspec +17 -10
- data/spec/bitboard_spec.rb +54 -0
- data/spec/board_spec.rb +53 -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 +71 -65
- data/spec/game_history_spec.rb +46 -0
- data/spec/game_spec.rb +112 -15
- data/spec/lexer_spec.rb +5 -5
- data/spec/movetext_clean_spec.rb +44 -0
- data/spec/node_spec.rb +240 -0
- data/spec/notation_spec.rb +5 -0
- data/spec/outcome_spec.rb +93 -0
- data/spec/parser_left_recursion_spec.rb +37 -0
- data/spec/parser_spec.rb +8 -1
- data/spec/position_attack_spec.rb +56 -0
- data/spec/position_legal_spec.rb +123 -0
- data/spec/position_spec.rb +128 -27
- data/spec/serializer_spec.rb +4 -4
- data/spec/zobrist_spec.rb +46 -0
- metadata +113 -35
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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module PGN
|
|
2
4
|
# {PGN::Position} encapsulates all of the information necessary to
|
|
3
5
|
# completely understand a chess position. It can be turned into a FEN string
|
|
@@ -31,12 +33,7 @@ module PGN
|
|
|
31
33
|
PLAYERS = %i[white black].freeze
|
|
32
34
|
CASTLING = %w[K Q k q].freeze
|
|
33
35
|
|
|
34
|
-
attr_accessor :board
|
|
35
|
-
attr_accessor :player
|
|
36
|
-
attr_accessor :castling
|
|
37
|
-
attr_accessor :en_passant
|
|
38
|
-
attr_accessor :halfmove
|
|
39
|
-
attr_accessor :fullmove
|
|
36
|
+
attr_accessor :board, :player, :castling, :en_passant, :halfmove, :fullmove
|
|
40
37
|
|
|
41
38
|
# @return [PGN::Position] the starting position of a chess game
|
|
42
39
|
#
|
|
@@ -83,17 +80,10 @@ module PGN
|
|
|
83
80
|
|
|
84
81
|
restrictions = calculator.castling_restrictions
|
|
85
82
|
new_castling = restrictions.empty? ? castling : castling - restrictions
|
|
86
|
-
new_halfmove =
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
end
|
|
91
|
-
new_fullmove = if calculator.increment_fullmove?
|
|
92
|
-
fullmove + 1
|
|
93
|
-
else
|
|
94
|
-
fullmove
|
|
95
|
-
end
|
|
96
|
-
no_move = str == '--'
|
|
83
|
+
new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0
|
|
84
|
+
new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove
|
|
85
|
+
no_move = str == '--'
|
|
86
|
+
|
|
97
87
|
PGN::Position.new(
|
|
98
88
|
no_move ? board : calculator.result_board,
|
|
99
89
|
next_player,
|
|
@@ -110,8 +100,153 @@ module PGN
|
|
|
110
100
|
player == :white ? :black : :white
|
|
111
101
|
end
|
|
112
102
|
|
|
103
|
+
# The perft node count at +depth+ from this position, computed by the
|
|
104
|
+
# native bitboard engine via a FEN round-trip. Requires the compiled
|
|
105
|
+
# native extension (the shipped gem); raises NameError if it is absent.
|
|
106
|
+
#
|
|
107
|
+
# @param depth [Integer] search depth, >= 0
|
|
108
|
+
# @return [Integer]
|
|
109
|
+
#
|
|
110
|
+
def perft(depth)
|
|
111
|
+
raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0
|
|
112
|
+
|
|
113
|
+
PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# All legal moves from this position as sorted UCI strings
|
|
117
|
+
# (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed
|
|
118
|
+
# by the native bitboard engine via a FEN round-trip. Requires the
|
|
119
|
+
# compiled native extension; raises NameError if it is absent.
|
|
120
|
+
#
|
|
121
|
+
# @return [Array<String>] sorted lexicographically
|
|
122
|
+
#
|
|
123
|
+
def legal_moves
|
|
124
|
+
PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves
|
|
125
|
+
end
|
|
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
|
+
|
|
113
164
|
def inspect
|
|
114
|
-
"\n
|
|
165
|
+
"\n#{board.inspect}"
|
|
166
|
+
end
|
|
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
|
+
king = PGN::Attack.king_idx(board, mover_color)
|
|
183
|
+
!king.nil? && PGN::Attack.attacked?(board, king, opponent_color)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The algebraic squares of every piece of the given color that attacks
|
|
187
|
+
# +square+ (algebraic, e.g. "e4"). Defaults to the side to move's
|
|
188
|
+
# opponent, which is useful for check detection.
|
|
189
|
+
#
|
|
190
|
+
# @param square [String] e.g. "e4"
|
|
191
|
+
# @param color [String, nil] 'w' or 'b'; defaults to the opponent color
|
|
192
|
+
# @return [Array<String>] squares attacking +square+
|
|
193
|
+
def attackers(square, color = opponent_color)
|
|
194
|
+
PGN::Attack.attackers(board, board.index_of(square), color)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Whether the side to move has been checkmated (in check and no legal
|
|
198
|
+
# move). Requires the native extension for legal-move enumeration.
|
|
199
|
+
#
|
|
200
|
+
# @return [Boolean]
|
|
201
|
+
def checkmate?
|
|
202
|
+
in_check? && legal_moves.empty?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Whether the side to move has been stalemated (not in check and no
|
|
206
|
+
# legal move). Requires the native extension.
|
|
207
|
+
#
|
|
208
|
+
# @return [Boolean]
|
|
209
|
+
def stalemate?
|
|
210
|
+
!in_check? && legal_moves.empty?
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Whether the position has insufficient material to mate. Covers K vs K,
|
|
214
|
+
# K + one minor vs K, and same-colored bishops only.
|
|
215
|
+
#
|
|
216
|
+
# @return [Boolean]
|
|
217
|
+
def insufficient_material?
|
|
218
|
+
non_king = (0...128).each_with_object([]) do |idx, acc|
|
|
219
|
+
next if idx.anybits?(0x88)
|
|
220
|
+
|
|
221
|
+
piece = board.at_index(idx)
|
|
222
|
+
acc << [piece, idx] if piece && piece.upcase != 'K'
|
|
223
|
+
end
|
|
224
|
+
return true if non_king.empty?
|
|
225
|
+
return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase)
|
|
226
|
+
|
|
227
|
+
bishops_same_color?(non_king)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Whether the 50-move rule applies (100 halfmoves since the last pawn
|
|
231
|
+
# move or capture).
|
|
232
|
+
#
|
|
233
|
+
# @return [Boolean]
|
|
234
|
+
def fifty_move?
|
|
235
|
+
halfmove >= 100
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The terminal status of this position: :checkmate, :stalemate, or :draw
|
|
239
|
+
# (insufficient material or 50-move rule). nil if the position is still
|
|
240
|
+
# in progress. Threefold repetition requires game history and is
|
|
241
|
+
# answered by {PGN::Game#outcome}.
|
|
242
|
+
#
|
|
243
|
+
# @return [Symbol, nil]
|
|
244
|
+
def outcome
|
|
245
|
+
return :checkmate if checkmate?
|
|
246
|
+
return :stalemate if stalemate?
|
|
247
|
+
return :draw if insufficient_material? || fifty_move?
|
|
248
|
+
|
|
249
|
+
nil
|
|
115
250
|
end
|
|
116
251
|
|
|
117
252
|
# @return [PGN::FEN] a {PGN::FEN} object representing the current position
|
|
@@ -120,11 +255,108 @@ module PGN
|
|
|
120
255
|
PGN::FEN.from_attributes(
|
|
121
256
|
board: board,
|
|
122
257
|
active: player == :white ? 'w' : 'b',
|
|
123
|
-
castling: castling.join
|
|
258
|
+
castling: castling.join,
|
|
124
259
|
en_passant: en_passant,
|
|
125
260
|
halfmove: halfmove.to_s,
|
|
126
261
|
fullmove: fullmove.to_s
|
|
127
262
|
)
|
|
128
263
|
end
|
|
264
|
+
|
|
265
|
+
# Positions are equal when their board, side to move, castling rights,
|
|
266
|
+
# and en-passant square match. Halfmove/fullmove counters are ignored
|
|
267
|
+
# (matching threefold-repetition semantics).
|
|
268
|
+
def eql?(other)
|
|
269
|
+
other.is_a?(PGN::Position) &&
|
|
270
|
+
player == other.player &&
|
|
271
|
+
castling == other.castling &&
|
|
272
|
+
en_passant == other.en_passant &&
|
|
273
|
+
zobrist == other.zobrist &&
|
|
274
|
+
board == other.board
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
alias == eql?
|
|
278
|
+
|
|
279
|
+
def hash
|
|
280
|
+
zobrist
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The Zobrist hash of the position. Computed lazily on first access and
|
|
284
|
+
# cached, so the replay hot path (which never asks for the hash) pays
|
|
285
|
+
# nothing; consumers like threefold-repetition checks pay one full seed.
|
|
286
|
+
#
|
|
287
|
+
# @return [Integer]
|
|
288
|
+
def zobrist
|
|
289
|
+
@zobrist ||= Zobrist.seed(board, player, castling, en_passant)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
private
|
|
293
|
+
|
|
294
|
+
# True when +move+ looks like a UCI coordinate string ("e2e4",
|
|
295
|
+
# "e1g1", "a7a8q"), so it can be handed straight to the engine.
|
|
296
|
+
def uci?(move)
|
|
297
|
+
move.is_a?(String) && move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Convert a UCI string to SAN using the current position, for
|
|
301
|
+
# {#legal_moves_san}. Promotion (if present) is passed as the letter.
|
|
302
|
+
def uci_to_san(uci)
|
|
303
|
+
from = uci[0, 2]
|
|
304
|
+
to = uci[2, 2]
|
|
305
|
+
promo = uci[4]
|
|
306
|
+
PGN::Notation.san(self, from, to, promo)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Map a castling side letter from {PGN::Move#castle} to the king's
|
|
310
|
+
# from/to UCI squares for the side to move.
|
|
311
|
+
CASTLE_UCI = {
|
|
312
|
+
'K' => 'e1g1',
|
|
313
|
+
'Q' => 'e1c1',
|
|
314
|
+
'k' => 'e8g8',
|
|
315
|
+
'q' => 'e8c8'
|
|
316
|
+
}.freeze
|
|
317
|
+
private_constant :CASTLE_UCI
|
|
318
|
+
|
|
319
|
+
# Does the legal UCI move +uci+ match the parsed SAN +move+? A move
|
|
320
|
+
# matches when destination, piece, promotion, and castling all agree,
|
|
321
|
+
# and the origin square satisfies +move+'s disambiguation (if any).
|
|
322
|
+
def matches_san?(uci, move)
|
|
323
|
+
return uci == CASTLE_UCI[move.castle] if move.castle
|
|
324
|
+
|
|
325
|
+
to = uci[2, 2]
|
|
326
|
+
return false if move.destination != to
|
|
327
|
+
|
|
328
|
+
from_idx = board.index_of(uci[0, 2])
|
|
329
|
+
return false if board.at_index(from_idx) != move.piece
|
|
330
|
+
|
|
331
|
+
promo = uci[4]
|
|
332
|
+
return false if move.promotion&.downcase != promo
|
|
333
|
+
|
|
334
|
+
disambiguation_matches?(move.disambiguation, from_idx)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Whether the disambiguation string from SAN (a file, a rank, or a full
|
|
338
|
+
# square) describes the origin square at +from_idx+. nil disambiguation
|
|
339
|
+
# matches any origin (ambiguity is handled by the caller counting matches).
|
|
340
|
+
def disambiguation_matches?(disambiguation, from_idx)
|
|
341
|
+
return true if disambiguation.nil? || disambiguation.empty?
|
|
342
|
+
|
|
343
|
+
file = Board::INDEX_TO_FILE[from_idx & 0x0F]
|
|
344
|
+
rank = Board::INDEX_TO_RANK[from_idx >> 4]
|
|
345
|
+
case disambiguation
|
|
346
|
+
when /\A[a-h]\z/ then file == disambiguation
|
|
347
|
+
when /\A[1-8]\z/ then rank == disambiguation
|
|
348
|
+
else file + rank == disambiguation
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Whether +non_king+ is all bishops on squares of the same color (a known
|
|
353
|
+
# insufficient-material draw, e.g. KB vs KB with same-colored bishops).
|
|
354
|
+
def bishops_same_color?(non_king)
|
|
355
|
+
return false unless non_king.all? { |piece, _| piece.upcase == 'B' }
|
|
356
|
+
return false if non_king.size > 2
|
|
357
|
+
|
|
358
|
+
colors = non_king.map { |_piece, idx| ((idx & 0x0F) + (idx >> 4)) % 2 }
|
|
359
|
+
colors.uniq.size == 1
|
|
360
|
+
end
|
|
129
361
|
end
|
|
130
362
|
end
|
data/lib/pgn/serializer.rb
CHANGED
|
@@ -17,7 +17,7 @@ module PGN
|
|
|
17
17
|
|
|
18
18
|
# @return [String] a canonical PGN string ending with a trailing newline
|
|
19
19
|
def to_s
|
|
20
|
-
tag_section
|
|
20
|
+
"#{tag_section}\n\n#{movetext_section}\n"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
private
|
|
@@ -39,18 +39,16 @@ module PGN
|
|
|
39
39
|
# result, all joined by spaces.
|
|
40
40
|
def movetext_section
|
|
41
41
|
tokens = []
|
|
42
|
-
if @game.comment && !@game.comment.empty?
|
|
43
|
-
tokens << "{ #{escape_comment(@game.comment)} }"
|
|
44
|
-
end
|
|
42
|
+
tokens << "{ #{escape_comment(@game.comment)} }" if @game.comment && !@game.comment.empty?
|
|
45
43
|
line = emit_line(@game.moves, starting_fullmove, starting_player)
|
|
46
44
|
tokens << line unless line.empty?
|
|
47
45
|
tokens << result_token
|
|
48
|
-
tokens.join(
|
|
46
|
+
tokens.join(' ')
|
|
49
47
|
end
|
|
50
48
|
|
|
51
49
|
# The result token: the game's result if present and non-empty, else "*".
|
|
52
50
|
def result_token
|
|
53
|
-
|
|
51
|
+
@game.result.nil? || @game.result.empty? ? '*' : @game.result
|
|
54
52
|
end
|
|
55
53
|
|
|
56
54
|
# Emit a single line (mainline or variation) of movetext, tracking the
|
|
@@ -63,12 +61,11 @@ module PGN
|
|
|
63
61
|
moves.each do |move|
|
|
64
62
|
if player == :white
|
|
65
63
|
tokens << "#{fullmove}."
|
|
66
|
-
tokens << move_token(move, fullmove, player)
|
|
67
64
|
else # black
|
|
68
65
|
need_number = prev_player.nil? || prev_had_extras || prev_player != :white
|
|
69
66
|
tokens << "#{fullmove}..." if need_number
|
|
70
|
-
tokens << move_token(move, fullmove, player)
|
|
71
67
|
end
|
|
68
|
+
tokens << move_token(move, fullmove, player)
|
|
72
69
|
|
|
73
70
|
prev_player = player
|
|
74
71
|
prev_had_extras = has_extras?(move)
|
|
@@ -76,7 +73,7 @@ module PGN
|
|
|
76
73
|
player = opposite(player)
|
|
77
74
|
end
|
|
78
75
|
|
|
79
|
-
tokens.join(
|
|
76
|
+
tokens.join(' ')
|
|
80
77
|
end
|
|
81
78
|
|
|
82
79
|
# A move token: notation plus trailing extras (annotation, comment,
|
|
@@ -85,13 +82,11 @@ module PGN
|
|
|
85
82
|
def move_token(move, fullmove, player)
|
|
86
83
|
parts = [move.notation]
|
|
87
84
|
(move.annotation || []).each { |a| parts << a }
|
|
88
|
-
if move.comment && !move.comment.empty?
|
|
89
|
-
parts << "{ #{escape_comment(move.comment)} }"
|
|
90
|
-
end
|
|
85
|
+
parts << "{ #{escape_comment(move.comment)} }" if move.comment && !move.comment.empty?
|
|
91
86
|
(move.variations || []).each do |variation|
|
|
92
87
|
parts << "(#{emit_line(variation, fullmove, player)})"
|
|
93
88
|
end
|
|
94
|
-
parts.join(
|
|
89
|
+
parts.join(' ')
|
|
95
90
|
end
|
|
96
91
|
|
|
97
92
|
# Whether a move carries any annotation, comment, or variation.
|
|
@@ -118,8 +113,8 @@ module PGN
|
|
|
118
113
|
# string replacement would otherwise re-interpret backslashes).
|
|
119
114
|
def escape_tag(value)
|
|
120
115
|
value.to_s
|
|
121
|
-
.gsub(
|
|
122
|
-
.gsub('"') { "
|
|
116
|
+
.gsub('\\') { '\\\\' }
|
|
117
|
+
.gsub('"') { '\"' }
|
|
123
118
|
end
|
|
124
119
|
|
|
125
120
|
# Escape backslashes and braces for a comment body (block form, so the
|
|
@@ -133,9 +128,9 @@ module PGN
|
|
|
133
128
|
# acknowledged v1 limitation.
|
|
134
129
|
def escape_comment(text)
|
|
135
130
|
text.to_s
|
|
136
|
-
.gsub(
|
|
137
|
-
.gsub(
|
|
138
|
-
.gsub(
|
|
131
|
+
.gsub('\\') { '\\\\' }
|
|
132
|
+
.gsub('{') { '\\{' }
|
|
133
|
+
.gsub('}') { '\\}' }
|
|
139
134
|
end
|
|
140
135
|
end
|
|
141
136
|
end
|
data/lib/pgn/version.rb
CHANGED
data/lib/pgn/zobrist.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGN
|
|
4
|
+
# Zobrist hashing keys for incremental position hashing. All keys are
|
|
5
|
+
# fixed 64-bit pseudo-random Integers generated once at load time from a
|
|
6
|
+
# frozen seed so hashes are stable for the life of the process.
|
|
7
|
+
#
|
|
8
|
+
# Indexing is by 0x88 square index (0..127); off-board indices are
|
|
9
|
+
# allocated but never read.
|
|
10
|
+
module Zobrist
|
|
11
|
+
SEED = 0x1234_5678_9abc_def1
|
|
12
|
+
|
|
13
|
+
# Deterministic pseudo-random generator so hashes are stable per
|
|
14
|
+
# process and across machines (no Kernel#rand).
|
|
15
|
+
def self.gen
|
|
16
|
+
@gen ||= Random.new(SEED)
|
|
17
|
+
end
|
|
18
|
+
private_class_method :gen
|
|
19
|
+
|
|
20
|
+
def self.rand64
|
|
21
|
+
gen.rand(1 << 64)
|
|
22
|
+
end
|
|
23
|
+
private_class_method :rand64
|
|
24
|
+
|
|
25
|
+
PIECES = %w[P N B R Q K p n b r q k].freeze
|
|
26
|
+
|
|
27
|
+
TABLE = PIECES.to_h { |piece| [piece, Array.new(128) { rand64 }] }.freeze
|
|
28
|
+
|
|
29
|
+
SIDE = rand64
|
|
30
|
+
CASTLING = { 'K' => rand64, 'Q' => rand64, 'k' => rand64, 'q' => rand64 }.freeze
|
|
31
|
+
EP_FILE = Array.new(8) { rand64 }.freeze
|
|
32
|
+
|
|
33
|
+
# @param board [PGN::Board]
|
|
34
|
+
# @param player [Symbol] :white or :black
|
|
35
|
+
# @param castling [Array<String>] e.g. %w[K Q k q]
|
|
36
|
+
# @param en_passant [String, nil] e.g. "e3" or nil
|
|
37
|
+
# @return [Integer] the Zobrist hash of the position
|
|
38
|
+
def self.seed(board, player, castling, en_passant)
|
|
39
|
+
h = 0
|
|
40
|
+
0.upto(7) do |rank|
|
|
41
|
+
0.upto(7) do |file|
|
|
42
|
+
idx = board.index_for(file, rank)
|
|
43
|
+
piece = board.at_index(idx)
|
|
44
|
+
h ^= TABLE[piece][idx] if piece
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
h ^= SIDE if player == :black
|
|
48
|
+
castling.to_a.each { |right| h ^= CASTLING[right] if CASTLING.key?(right) }
|
|
49
|
+
h ^= EP_FILE[Board::FILE_TO_INDEX[en_passant[0]]] if en_passant && !en_passant.empty?
|
|
50
|
+
h
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/pgn.rb
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
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'
|
|
11
|
+
require 'pgn/zobrist'
|
|
8
12
|
require 'pgn/pgn_parser'
|
|
9
13
|
require 'pgn/parser'
|
|
10
14
|
require 'pgn/position'
|
|
11
15
|
require 'pgn/serializer'
|
|
12
16
|
require 'pgn/version'
|
|
17
|
+
require 'pgn/bitboard'
|
|
13
18
|
|
|
14
19
|
module PGN
|
|
15
20
|
# @param pgn [String] a pgn representation of one or more chess games
|