pgn2 1.4.0 → 2.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 +34 -3
- data/.github/workflows/native.yml +32 -0
- data/.github/workflows/publish.yml +44 -2
- data/.github/workflows/release-gems.yml +40 -0
- data/.github/workflows/release.yml +52 -5
- data/.gitignore +9 -1
- data/.rubocop.yml +46 -6
- data/CHANGELOG.md +183 -1
- data/Gemfile +3 -0
- data/NOTICE.md +21 -0
- data/README.md +107 -5
- data/Rakefile +35 -10
- data/TODO.md +102 -31
- 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/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/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/bitboard.rb +13 -0
- data/lib/pgn/board.rb +103 -10
- data/lib/pgn/fen.rb +35 -46
- data/lib/pgn/game.rb +22 -13
- data/lib/pgn/lexer.rb +9 -6
- data/lib/pgn/move.rb +19 -15
- data/lib/pgn/move_calculator.rb +46 -20
- data/lib/pgn/notation.rb +24 -29
- data/lib/pgn/position.rb +60 -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 +2 -0
- data/pgn2.gemspec +17 -10
- data/spec/bitboard_spec.rb +54 -0
- data/spec/board_spec.rb +53 -0
- data/spec/fen_spec.rb +65 -65
- data/spec/game_spec.rb +52 -15
- data/spec/lexer_spec.rb +5 -5
- data/spec/notation_spec.rb +5 -0
- data/spec/parser_spec.rb +8 -1
- data/spec/position_spec.rb +128 -27
- data/spec/serializer_spec.rb +4 -4
- data/spec/zobrist_spec.rb +46 -0
- metadata +101 -36
data/lib/pgn/board.rb
CHANGED
|
@@ -27,10 +27,35 @@ module PGN
|
|
|
27
27
|
].freeze
|
|
28
28
|
|
|
29
29
|
FILE_TO_INDEX = ('a'..'h').each_with_index.to_h
|
|
30
|
-
INDEX_TO_FILE = FILE_TO_INDEX.
|
|
30
|
+
INDEX_TO_FILE = FILE_TO_INDEX.invert
|
|
31
31
|
|
|
32
32
|
RANK_TO_INDEX = ('1'..'8').each_with_index.to_h
|
|
33
|
-
INDEX_TO_RANK = RANK_TO_INDEX.
|
|
33
|
+
INDEX_TO_RANK = RANK_TO_INDEX.invert
|
|
34
|
+
|
|
35
|
+
# 0x88 knight offsets (a1 + 33 = b3, etc.).
|
|
36
|
+
KNIGHT_OFFS = [33, 31, -31, -33, 18, 14, -14, -18].freeze
|
|
37
|
+
KING_OFFS = [-1, 1, -16, 16, -15, 15, -17, 17].freeze
|
|
38
|
+
|
|
39
|
+
# Precomputed on-board attack masks: entry `idx` is the frozen Array of
|
|
40
|
+
# on-board 0x88 target indices reachable from `idx` by that piece. Built
|
|
41
|
+
# once at load time so the per-call offset + off-board test is replaced by
|
|
42
|
+
# a direct array iteration.
|
|
43
|
+
KNIGHT_ATTACKS = Array.new(128) do |idx|
|
|
44
|
+
next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate
|
|
45
|
+
|
|
46
|
+
KNIGHT_OFFS.each_with_object([]) do |off, a|
|
|
47
|
+
t = idx + off
|
|
48
|
+
a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate
|
|
49
|
+
end.freeze
|
|
50
|
+
end.freeze
|
|
51
|
+
KING_ATTACKS = Array.new(128) do |idx|
|
|
52
|
+
next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate
|
|
53
|
+
|
|
54
|
+
KING_OFFS.each_with_object([]) do |off, a|
|
|
55
|
+
t = idx + off
|
|
56
|
+
a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate
|
|
57
|
+
end.freeze
|
|
58
|
+
end.freeze
|
|
34
59
|
|
|
35
60
|
# algebraic to unicode piece lookup
|
|
36
61
|
#
|
|
@@ -102,7 +127,6 @@ module PGN
|
|
|
102
127
|
@cells[(r * 16) + f] = squares[f][r]
|
|
103
128
|
end
|
|
104
129
|
end
|
|
105
|
-
@cells
|
|
106
130
|
end
|
|
107
131
|
|
|
108
132
|
# @overload at(str)
|
|
@@ -119,9 +143,9 @@ module PGN
|
|
|
119
143
|
# board.at("e4") #=> "P"
|
|
120
144
|
#
|
|
121
145
|
def at(arg0, arg1 = nil)
|
|
122
|
-
return
|
|
146
|
+
return at_index(index_for(arg0, arg1)) unless arg1.nil?
|
|
123
147
|
|
|
124
|
-
|
|
148
|
+
at_index(index_of(arg0))
|
|
125
149
|
end
|
|
126
150
|
|
|
127
151
|
# @param changes [Hash<String, <String, nil>>] changes to make to the board
|
|
@@ -141,8 +165,7 @@ module PGN
|
|
|
141
165
|
# board.update("e4", "P")
|
|
142
166
|
#
|
|
143
167
|
def update(square, piece)
|
|
144
|
-
|
|
145
|
-
self
|
|
168
|
+
update_index(index_of(square), piece)
|
|
146
169
|
end
|
|
147
170
|
|
|
148
171
|
# @param position [String] the square in algebraic notation
|
|
@@ -173,13 +196,25 @@ module PGN
|
|
|
173
196
|
end.join("\n")
|
|
174
197
|
end
|
|
175
198
|
|
|
199
|
+
# Build a {Board} directly from a 0x88 cell array, bypassing the 8x8
|
|
200
|
+
# -> 0x88 conversion in {#initialize}. Used by {#dup} (which runs every
|
|
201
|
+
# move) to skip the per-square rebuild; the cell array is already in the
|
|
202
|
+
# canonical 128-cell layout.
|
|
203
|
+
#
|
|
204
|
+
# @param cells [Array<String, nil>] a 128-cell 0x88 array
|
|
205
|
+
# @return [PGN::Board]
|
|
206
|
+
#
|
|
207
|
+
def self.from_cells(cells)
|
|
208
|
+
board = allocate
|
|
209
|
+
board.instance_variable_set(:@cells, cells)
|
|
210
|
+
board
|
|
211
|
+
end
|
|
212
|
+
|
|
176
213
|
# @return [PGN::Board] a copy of self. Copies the 128-cell 0x88 array;
|
|
177
214
|
# mutations to the copy do not affect the original.
|
|
178
215
|
#
|
|
179
216
|
def dup
|
|
180
|
-
|
|
181
|
-
copy.instance_variable_set(:@cells, @cells.dup)
|
|
182
|
-
copy
|
|
217
|
+
self.class.from_cells(@cells.dup)
|
|
183
218
|
end
|
|
184
219
|
|
|
185
220
|
# -- 0x88 hot-path API (integer indices) ---------------------------------
|
|
@@ -235,6 +270,64 @@ module PGN
|
|
|
235
270
|
self
|
|
236
271
|
end
|
|
237
272
|
|
|
273
|
+
# Whether a 0x88 index is on the board (see the class doc for the
|
|
274
|
+
# bitmask this tests).
|
|
275
|
+
#
|
|
276
|
+
# @param idx [Integer] a 0x88 square index
|
|
277
|
+
# @return [Boolean]
|
|
278
|
+
#
|
|
279
|
+
def on_board?(idx)
|
|
280
|
+
(idx & 0x88).zero? # rubocop:disable Style/BitwisePredicate
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The algebraic square name of a 0x88 index.
|
|
284
|
+
#
|
|
285
|
+
# @param idx [Integer] a 0x88 square index
|
|
286
|
+
# @return [String] e.g. "e4"
|
|
287
|
+
#
|
|
288
|
+
def square_name(idx)
|
|
289
|
+
INDEX_TO_FILE[idx & 0x0F] + INDEX_TO_RANK[idx >> 4]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Serializes the board to the FEN board-string portion (ranks 8→1,
|
|
293
|
+
# files a→h, runs of empty squares collapsed to a digit) by walking
|
|
294
|
+
# the 0x88 `@cells` array directly. This avoids rebuilding the 8x8
|
|
295
|
+
# `squares` array on every FEN generation.
|
|
296
|
+
#
|
|
297
|
+
# @return [String] e.g. "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
298
|
+
def fen_board_string
|
|
299
|
+
rows = []
|
|
300
|
+
7.downto(0) do |rank|
|
|
301
|
+
s = String.new
|
|
302
|
+
run = 0
|
|
303
|
+
0.upto(7) do |file|
|
|
304
|
+
piece = @cells[(rank * 16) + file]
|
|
305
|
+
if piece.nil?
|
|
306
|
+
run += 1
|
|
307
|
+
else
|
|
308
|
+
s << run.to_s if run.positive?
|
|
309
|
+
run = 0
|
|
310
|
+
s << piece
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
s << run.to_s if run.positive?
|
|
314
|
+
rows << s
|
|
315
|
+
end
|
|
316
|
+
rows.join('/')
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Boards are equal when every cell holds the same piece (or is
|
|
320
|
+
# empty), including off-board padding, which is always nil on both.
|
|
321
|
+
def eql?(other)
|
|
322
|
+
other.is_a?(Board) && cells == other.cells
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
alias == eql?
|
|
326
|
+
|
|
327
|
+
protected
|
|
328
|
+
|
|
329
|
+
attr_reader :cells
|
|
330
|
+
|
|
238
331
|
private
|
|
239
332
|
|
|
240
333
|
def file_of(square)
|
data/lib/pgn/fen.rb
CHANGED
|
@@ -37,9 +37,13 @@ module PGN
|
|
|
37
37
|
class FEN
|
|
38
38
|
# The FEN string representing the starting position in chess
|
|
39
39
|
#
|
|
40
|
-
INITIAL =
|
|
40
|
+
INITIAL = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'.freeze
|
|
41
41
|
|
|
42
|
-
attr_accessor :board, :active, :
|
|
42
|
+
attr_accessor :board, :active, :halfmove, :fullmove
|
|
43
|
+
# `castling` and `en_passant` have custom writers below (normalize nil/empty
|
|
44
|
+
# to '-'), so expose readers here and define the writers explicitly to avoid
|
|
45
|
+
# redefining the accessors generated by attr_accessor (Lint/DuplicateMethods).
|
|
46
|
+
attr_reader :castling, :en_passant
|
|
43
47
|
|
|
44
48
|
# @return [PGN::FEN] a {PGN::FEN} object representing the starting
|
|
45
49
|
# position
|
|
@@ -61,22 +65,22 @@ module PGN
|
|
|
61
65
|
# @param fen_string [String] a string in Forsyth-Edwards Notation
|
|
62
66
|
#
|
|
63
67
|
def initialize(fen_string = nil)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
return unless fen_string
|
|
69
|
+
|
|
70
|
+
self.board_string,
|
|
71
|
+
self.active,
|
|
72
|
+
self.castling,
|
|
73
|
+
self.en_passant,
|
|
74
|
+
self.halfmove,
|
|
75
|
+
self.fullmove = fen_string.split
|
|
72
76
|
end
|
|
73
77
|
|
|
74
78
|
def en_passant=(val)
|
|
75
|
-
@en_passant = val.nil? ?
|
|
79
|
+
@en_passant = val.nil? ? '-' : val
|
|
76
80
|
end
|
|
77
81
|
|
|
78
82
|
def castling=(val)
|
|
79
|
-
@castling =
|
|
83
|
+
@castling = val.nil? || val.empty? ? '-' : val
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
# @param board_fen [String] the fen representation of the board
|
|
@@ -84,10 +88,10 @@ module PGN
|
|
|
84
88
|
# fen.board_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
85
89
|
#
|
|
86
90
|
def board_string=(board_fen)
|
|
87
|
-
squares = board_fen.gsub(/\d/) {|match|
|
|
88
|
-
.split(
|
|
89
|
-
.map
|
|
90
|
-
.map {|row| row.map {|e| e ==
|
|
91
|
+
squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
|
|
92
|
+
.split('/')
|
|
93
|
+
.map(&:chars)
|
|
94
|
+
.map { |row| row.map { |e| e == '_' ? nil : e } }
|
|
91
95
|
.reverse
|
|
92
96
|
.transpose
|
|
93
97
|
self.board = PGN::Board.new(squares)
|
|
@@ -98,39 +102,24 @@ module PGN
|
|
|
98
102
|
# PGN::FEN.start.board_string #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
99
103
|
#
|
|
100
104
|
def board_string
|
|
101
|
-
|
|
102
|
-
rows.map do |row|
|
|
103
|
-
s = +""
|
|
104
|
-
run = 0
|
|
105
|
-
row.each do |e|
|
|
106
|
-
if e.nil?
|
|
107
|
-
run += 1
|
|
108
|
-
else
|
|
109
|
-
s << run.to_s if run > 0
|
|
110
|
-
run = 0
|
|
111
|
-
s << e
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
s << run.to_s if run > 0
|
|
115
|
-
s
|
|
116
|
-
end.join("/")
|
|
105
|
+
board.fen_board_string
|
|
117
106
|
end
|
|
118
107
|
|
|
119
108
|
# @return [PGN::Position] a {PGN::Position} representing the current
|
|
120
109
|
# position
|
|
121
110
|
#
|
|
122
111
|
def to_position
|
|
123
|
-
player =
|
|
124
|
-
castling = self.castling.
|
|
125
|
-
en_passant = self.en_passant == '-'
|
|
112
|
+
player = active == 'w' ? :white : :black
|
|
113
|
+
castling = self.castling.chars - ['-']
|
|
114
|
+
en_passant = nil if self.en_passant == '-'
|
|
126
115
|
|
|
127
116
|
PGN::Position.new(
|
|
128
|
-
|
|
117
|
+
board,
|
|
129
118
|
player,
|
|
130
119
|
castling,
|
|
131
120
|
en_passant,
|
|
132
|
-
|
|
133
|
-
|
|
121
|
+
halfmove.to_i,
|
|
122
|
+
fullmove.to_i
|
|
134
123
|
)
|
|
135
124
|
end
|
|
136
125
|
|
|
@@ -140,17 +129,17 @@ module PGN
|
|
|
140
129
|
#
|
|
141
130
|
def to_s
|
|
142
131
|
[
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
].join(
|
|
132
|
+
board_string,
|
|
133
|
+
active,
|
|
134
|
+
castling,
|
|
135
|
+
en_passant,
|
|
136
|
+
halfmove,
|
|
137
|
+
fullmove
|
|
138
|
+
].join(' ')
|
|
150
139
|
end
|
|
151
140
|
|
|
152
141
|
def inspect
|
|
153
|
-
|
|
142
|
+
to_s
|
|
154
143
|
end
|
|
155
144
|
end
|
|
156
145
|
end
|
data/lib/pgn/game.rb
CHANGED
|
@@ -30,7 +30,9 @@ module PGN
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def clean_text(text)
|
|
33
|
-
|
|
33
|
+
return unless text
|
|
34
|
+
|
|
35
|
+
text.gsub(/{(.*)}/, '\1').gsub(/\s+/, ' ').strip
|
|
34
36
|
end
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -106,16 +108,25 @@ module PGN
|
|
|
106
108
|
# @return [Array<PGN::Position>] list of the {PGN::Position}s in the game
|
|
107
109
|
#
|
|
108
110
|
def positions
|
|
109
|
-
@positions ||=
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
111
|
+
@positions ||= each_position.to_a
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Enumerator, self] with a block: yields each {PGN::Position}
|
|
115
|
+
# in order (starting position, then one per move) and returns self.
|
|
116
|
+
# Without a block: returns an Enumerator that yields the same.
|
|
117
|
+
#
|
|
118
|
+
# The replay loop is shared with {#positions} so eager and lazy paths
|
|
119
|
+
# produce identical position objects in identical order.
|
|
120
|
+
def each_position
|
|
121
|
+
return enum_for(:each_position) unless block_given?
|
|
122
|
+
|
|
123
|
+
position = starting_position
|
|
124
|
+
yield position
|
|
125
|
+
moves.each do |move|
|
|
126
|
+
position = position.move(move.notation)
|
|
127
|
+
yield position
|
|
118
128
|
end
|
|
129
|
+
self
|
|
119
130
|
end
|
|
120
131
|
|
|
121
132
|
# @return [Array<String>] list of the fen representations of the positions
|
|
@@ -158,9 +169,7 @@ module PGN
|
|
|
158
169
|
return MoveText.new(entry.include?('0') ? entry.gsub('0', 'O') : entry) if entry.is_a?(String)
|
|
159
170
|
|
|
160
171
|
notation = entry.notation.include?('0') ? entry.notation.gsub('0', 'O') : entry.notation
|
|
161
|
-
if notation.equal?(entry.notation) && (entry.comment.nil? || !entry.comment.include?('{'))
|
|
162
|
-
return entry
|
|
163
|
-
end
|
|
172
|
+
return entry if notation.equal?(entry.notation) && (entry.comment.nil? || !entry.comment.include?('{'))
|
|
164
173
|
|
|
165
174
|
MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
|
|
166
175
|
end
|
data/lib/pgn/lexer.rb
CHANGED
|
@@ -161,7 +161,6 @@ module PGN
|
|
|
161
161
|
def initialize(input)
|
|
162
162
|
@input = input
|
|
163
163
|
@ss = StringScanner.new(input)
|
|
164
|
-
@line = 1
|
|
165
164
|
@game_starts = []
|
|
166
165
|
@between_games = true # at start we are "between" games
|
|
167
166
|
end
|
|
@@ -182,7 +181,8 @@ module PGN
|
|
|
182
181
|
type, value = next_token_pair
|
|
183
182
|
return nil unless type
|
|
184
183
|
|
|
185
|
-
Token.new(type: type, value: value, offset: @last_offset,
|
|
184
|
+
Token.new(type: type, value: value, offset: @last_offset,
|
|
185
|
+
line: line_at(@last_offset))
|
|
186
186
|
end
|
|
187
187
|
|
|
188
188
|
# Fast path for the parser: returns [type, value] for the next
|
|
@@ -203,7 +203,6 @@ module PGN
|
|
|
203
203
|
end
|
|
204
204
|
|
|
205
205
|
value = scan_one
|
|
206
|
-
advance_line(value)
|
|
207
206
|
next if @scan_discarded
|
|
208
207
|
|
|
209
208
|
note_token(@scan_type, off)
|
|
@@ -231,7 +230,7 @@ module PGN
|
|
|
231
230
|
return m
|
|
232
231
|
end
|
|
233
232
|
raise UnconsumedInputError,
|
|
234
|
-
"Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{@
|
|
233
|
+
"Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{line_at(@ss.pos)}"
|
|
235
234
|
end
|
|
236
235
|
|
|
237
236
|
# Track per-game content-start offsets for verbatim pgn slicing.
|
|
@@ -246,8 +245,12 @@ module PGN
|
|
|
246
245
|
end
|
|
247
246
|
end
|
|
248
247
|
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
# Line number at a byte offset, computed lazily only for error messages
|
|
249
|
+
# and the spec `tokens` helper. Keeping a running `@line` on the parse hot
|
|
250
|
+
# path (one `str.count("\n")` per token) was ~6% of parse CPU for a value
|
|
251
|
+
# the parser never reads.
|
|
252
|
+
def line_at(off)
|
|
253
|
+
1 + @input.byteslice(0, off).count("\n")
|
|
251
254
|
end
|
|
252
255
|
end
|
|
253
256
|
|
data/lib/pgn/move.rb
CHANGED
|
@@ -57,8 +57,12 @@ module PGN
|
|
|
57
57
|
#
|
|
58
58
|
|
|
59
59
|
class Move
|
|
60
|
-
attr_accessor :san, :player
|
|
61
|
-
|
|
60
|
+
attr_accessor :san, :player, :destination, :check
|
|
61
|
+
# piece/promotion/capture/disambiguation/castle have custom writers below
|
|
62
|
+
# (color normalization, nil-coalescing, etc.), so expose only readers here
|
|
63
|
+
# to avoid redefining attr_accessor setters (Lint/DuplicateMethods).
|
|
64
|
+
attr_reader :piece, :promotion, :capture, :disambiguation, :castle
|
|
65
|
+
|
|
62
66
|
# A regular expression for matching moves in standard algebraic
|
|
63
67
|
# notation
|
|
64
68
|
#
|
|
@@ -70,7 +74,7 @@ module PGN
|
|
|
70
74
|
(?<capture> x ){0}
|
|
71
75
|
(?<disambiguation> [a-h]?[1-8]? ){0}
|
|
72
76
|
|
|
73
|
-
(?<castle> O-O(
|
|
77
|
+
(?<castle> O-O(?:-O)? ){0}
|
|
74
78
|
|
|
75
79
|
(?<normal>
|
|
76
80
|
\g<piece>?
|
|
@@ -80,8 +84,8 @@ module PGN
|
|
|
80
84
|
\g<promotion>?
|
|
81
85
|
){0}
|
|
82
86
|
|
|
83
|
-
\A (
|
|
84
|
-
/x
|
|
87
|
+
\A (?:\g<castle> | \g<normal>) \g<check>? \z
|
|
88
|
+
/x
|
|
85
89
|
|
|
86
90
|
# @param move [String] the move in SAN
|
|
87
91
|
# @param player [Symbol] the player making the move
|
|
@@ -115,10 +119,10 @@ module PGN
|
|
|
115
119
|
end
|
|
116
120
|
|
|
117
121
|
def promotion=(val)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
return unless val
|
|
123
|
+
|
|
124
|
+
val.downcase! if black?
|
|
125
|
+
@promotion = val.delete('=')
|
|
122
126
|
end
|
|
123
127
|
|
|
124
128
|
def capture=(val)
|
|
@@ -130,11 +134,11 @@ module PGN
|
|
|
130
134
|
end
|
|
131
135
|
|
|
132
136
|
def castle=(val)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
return unless val
|
|
138
|
+
|
|
139
|
+
@castle = 'K' if val == 'O-O'
|
|
140
|
+
@castle = 'Q' if val == 'O-O-O'
|
|
141
|
+
@castle.downcase! if black?
|
|
138
142
|
end
|
|
139
143
|
|
|
140
144
|
# @return [Boolean] whether the move results in check
|
|
@@ -164,7 +168,7 @@ module PGN
|
|
|
164
168
|
# @return [Boolean] whether the piece being moved is a pawn
|
|
165
169
|
#
|
|
166
170
|
def pawn?
|
|
167
|
-
|
|
171
|
+
%w[P p].include?(piece)
|
|
168
172
|
end
|
|
169
173
|
end
|
|
170
174
|
end
|
data/lib/pgn/move_calculator.rb
CHANGED
|
@@ -30,7 +30,7 @@ module PGN
|
|
|
30
30
|
# 0x88 single-step offsets for knight and king.
|
|
31
31
|
#
|
|
32
32
|
STEP = {
|
|
33
|
-
'k' => [
|
|
33
|
+
'k' => SLIDE['q'],
|
|
34
34
|
'n' => [33, 31, -31, -33, 18, 14, -14, -18]
|
|
35
35
|
}.freeze
|
|
36
36
|
|
|
@@ -42,15 +42,6 @@ module PGN
|
|
|
42
42
|
'p' => { capture: [15, 17], normal: [16], double: [32] }
|
|
43
43
|
}.freeze
|
|
44
44
|
|
|
45
|
-
# The squares to update for each castling move, keyed by 0x88 index.
|
|
46
|
-
#
|
|
47
|
-
CASTLING = {
|
|
48
|
-
'Q' => { 0 => nil, 2 => 'K', 3 => 'R', 4 => nil },
|
|
49
|
-
'K' => { 4 => nil, 5 => 'R', 6 => 'K', 7 => nil },
|
|
50
|
-
'q' => { 112 => nil, 114 => 'k', 115 => 'r', 116 => nil },
|
|
51
|
-
'k' => { 116 => nil, 117 => 'r', 118 => 'k', 119 => nil }
|
|
52
|
-
}.freeze
|
|
53
|
-
|
|
54
45
|
# Corner-square 0x88 indices, used for castling-restriction bookkeeping
|
|
55
46
|
# (a rook leaving or being captured on a corner drops the matching right).
|
|
56
47
|
#
|
|
@@ -59,6 +50,29 @@ module PGN
|
|
|
59
50
|
A8 = 112
|
|
60
51
|
H8 = 119
|
|
61
52
|
|
|
53
|
+
# King/rook landing squares for castling, named for readability in
|
|
54
|
+
# {CASTLING} below.
|
|
55
|
+
#
|
|
56
|
+
C1 = 2
|
|
57
|
+
D1 = 3
|
|
58
|
+
E1 = 4
|
|
59
|
+
F1 = 5
|
|
60
|
+
G1 = 6
|
|
61
|
+
C8 = 114
|
|
62
|
+
D8 = 115
|
|
63
|
+
E8 = 116
|
|
64
|
+
F8 = 117
|
|
65
|
+
G8 = 118
|
|
66
|
+
|
|
67
|
+
# The squares to update for each castling move, keyed by 0x88 index.
|
|
68
|
+
#
|
|
69
|
+
CASTLING = {
|
|
70
|
+
'Q' => { A1 => nil, C1 => 'K', D1 => 'R', E1 => nil },
|
|
71
|
+
'K' => { E1 => nil, F1 => 'R', G1 => 'K', H1 => nil },
|
|
72
|
+
'q' => { A8 => nil, C8 => 'k', D8 => 'r', E8 => nil },
|
|
73
|
+
'k' => { E8 => nil, F8 => 'r', G8 => 'k', H8 => nil }
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
62
76
|
# rook-origin (0x88 index) -> castling restriction it drops.
|
|
63
77
|
#
|
|
64
78
|
ROOK_RESTRICTIONS = { A1 => 'Q', H1 => 'K', A8 => 'q', H8 => 'k' }.freeze
|
|
@@ -69,14 +83,14 @@ module PGN
|
|
|
69
83
|
WHITE_CASTLE = %w[K Q].freeze
|
|
70
84
|
BLACK_CASTLE = %w[k q].freeze
|
|
71
85
|
|
|
72
|
-
|
|
86
|
+
attr_reader :board, :move
|
|
73
87
|
|
|
74
88
|
# @param board [PGN::Board] the current board
|
|
75
89
|
# @param move [PGN::Move] the current move
|
|
76
90
|
#
|
|
77
91
|
def initialize(board, move)
|
|
78
|
-
|
|
79
|
-
|
|
92
|
+
@board = board
|
|
93
|
+
@move = move
|
|
80
94
|
@origin_idx = compute_origin
|
|
81
95
|
end
|
|
82
96
|
|
|
@@ -87,7 +101,7 @@ module PGN
|
|
|
87
101
|
def origin
|
|
88
102
|
return nil if @origin_idx.nil?
|
|
89
103
|
|
|
90
|
-
board.
|
|
104
|
+
board.square_name(@origin_idx)
|
|
91
105
|
end
|
|
92
106
|
|
|
93
107
|
# @return [PGN::Board] the board after the move is made
|
|
@@ -177,7 +191,7 @@ module PGN
|
|
|
177
191
|
|
|
178
192
|
possibilities = case move.piece
|
|
179
193
|
when 'B', 'R', 'Q', 'b', 'r', 'q' then direction_origins
|
|
180
|
-
when 'K', 'N', 'k', 'n' then
|
|
194
|
+
when 'K', 'N', 'k', 'n' then leaper_origins
|
|
181
195
|
when 'P', 'p' then pawn_origins
|
|
182
196
|
else # don't care move, used in variations
|
|
183
197
|
return nil
|
|
@@ -215,7 +229,7 @@ module PGN
|
|
|
215
229
|
possibilities = []
|
|
216
230
|
offsets.each do |off|
|
|
217
231
|
target = dest + off
|
|
218
|
-
next unless (target
|
|
232
|
+
next unless board.on_board?(target)
|
|
219
233
|
|
|
220
234
|
possibilities << target if board.at_index(target) == move.piece
|
|
221
235
|
end
|
|
@@ -223,6 +237,18 @@ module PGN
|
|
|
223
237
|
possibilities
|
|
224
238
|
end
|
|
225
239
|
|
|
240
|
+
# Knight/king origins use the precomputed on-board attack masks on
|
|
241
|
+
# {PGN::Board}, skipping the per-call off-board test.
|
|
242
|
+
def leaper_origins
|
|
243
|
+
dest = dest_idx
|
|
244
|
+
mask = move.piece.upcase == 'N' ? Board::KNIGHT_ATTACKS[dest] : Board::KING_ATTACKS[dest]
|
|
245
|
+
piece = move.piece
|
|
246
|
+
|
|
247
|
+
possibilities = []
|
|
248
|
+
mask.each { |t| possibilities << t if board.at_index(t) == piece }
|
|
249
|
+
possibilities
|
|
250
|
+
end
|
|
251
|
+
|
|
226
252
|
# Computes the possible pawn origins based on the destination square
|
|
227
253
|
# and whether or not the move is a capture.
|
|
228
254
|
#
|
|
@@ -250,7 +276,7 @@ module PGN
|
|
|
250
276
|
return possibilities unless move.disambiguation
|
|
251
277
|
|
|
252
278
|
possibilities.select do |idx|
|
|
253
|
-
board.
|
|
279
|
+
board.square_name(idx).match(move.disambiguation)
|
|
254
280
|
end
|
|
255
281
|
end
|
|
256
282
|
|
|
@@ -290,7 +316,7 @@ module PGN
|
|
|
290
316
|
#
|
|
291
317
|
def first_piece(idx, off)
|
|
292
318
|
idx += off
|
|
293
|
-
while (idx
|
|
319
|
+
while board.on_board?(idx)
|
|
294
320
|
square = board.at_index(idx)
|
|
295
321
|
return idx if square
|
|
296
322
|
|
|
@@ -315,7 +341,7 @@ module PGN
|
|
|
315
341
|
return nil if move.castle
|
|
316
342
|
return nil unless move.capture && board.at_index(dest_idx).nil?
|
|
317
343
|
|
|
318
|
-
(
|
|
344
|
+
board.index_for(dest_idx & 0x0F, origin_rank)
|
|
319
345
|
end
|
|
320
346
|
|
|
321
347
|
def king_position
|
|
@@ -323,7 +349,7 @@ module PGN
|
|
|
323
349
|
|
|
324
350
|
0.upto(7) do |rank|
|
|
325
351
|
0.upto(7) do |file|
|
|
326
|
-
idx = (rank
|
|
352
|
+
idx = board.index_for(file, rank)
|
|
327
353
|
return idx if board.at_index(idx) == king
|
|
328
354
|
end
|
|
329
355
|
end
|