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/node.rb
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGN
|
|
4
|
+
# {PGN::Node} is a live, lazily-built view over the {PGN::MoveText} tree.
|
|
5
|
+
#
|
|
6
|
+
# A node represents the position reached by playing +move+ from its
|
|
7
|
+
# +parent+'s position; the root has no move and is the game's starting
|
|
8
|
+
# position. The underlying {PGN::MoveText} structure is the source of
|
|
9
|
+
# truth (the parser builds it and the serializer reads it), so a node
|
|
10
|
+
# tree never disagrees with the serialized output.
|
|
11
|
+
#
|
|
12
|
+
# Mutations edit the underlying +MoveText+ arrays in place and, for
|
|
13
|
+
# sibling reorders, normalize the affected branching point to flat
|
|
14
|
+
# sibling storage. After any structural mutation, {PGN::Game#root}
|
|
15
|
+
# returns a fresh tree — outstanding node references are stale.
|
|
16
|
+
class Node
|
|
17
|
+
attr_reader :move, :parent, :line, :index
|
|
18
|
+
|
|
19
|
+
# @param move [PGN::MoveText, nil] the move played to reach this node
|
|
20
|
+
# @param parent [PGN::Node, nil] the parent node (nil for the root)
|
|
21
|
+
# @param line [Array<PGN::MoveText>, nil] the line this node's move
|
|
22
|
+
# lives in (the mainline for mainline nodes, the variation Array for
|
|
23
|
+
# variation nodes; nil conceptually for the root, which passes the
|
|
24
|
+
# mainline)
|
|
25
|
+
# @param index [Integer] index of this node's move within +line+
|
|
26
|
+
# (-1 for the root)
|
|
27
|
+
# @param starting_position [PGN::Position] the root's position
|
|
28
|
+
# @param game [PGN::Game] back-reference so mutations can return a
|
|
29
|
+
# fresh root
|
|
30
|
+
def initialize(move:, parent:, line:, index:, starting_position: nil, game: nil)
|
|
31
|
+
@move = move
|
|
32
|
+
@parent = parent
|
|
33
|
+
@line = line
|
|
34
|
+
@index = index
|
|
35
|
+
@starting_position = starting_position
|
|
36
|
+
@game = game
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def root?
|
|
40
|
+
@move.nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def notation
|
|
44
|
+
@move&.notation
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def annotation
|
|
48
|
+
@move&.annotation
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def comment
|
|
52
|
+
@move&.comment
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# All moves playable from this node's position: the continuation move
|
|
56
|
+
# (the next MoveText in this node's line) plus every variation
|
|
57
|
+
# first-move branching at that same position, recursively through
|
|
58
|
+
# nested brackets. The first child is the mainline continuation; the
|
|
59
|
+
# rest are variations in source order.
|
|
60
|
+
def children
|
|
61
|
+
@children ||= begin
|
|
62
|
+
cont = continuation_movetext
|
|
63
|
+
list = []
|
|
64
|
+
collect_first_moves(cont, @line, @index + 1) do |mt, l, idx|
|
|
65
|
+
list << Node.new(move: mt, parent: self, line: l, index: idx, game: @game)
|
|
66
|
+
end
|
|
67
|
+
list
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The non-mainline alternatives at this node's position.
|
|
72
|
+
def variations
|
|
73
|
+
children[1..] || []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The mainline continuation, or nil at a terminal position.
|
|
77
|
+
# (Defined via +define_method+ because +next+ is a Ruby keyword and
|
|
78
|
+
# cannot be used with +def+.)
|
|
79
|
+
define_method(:next) { children.first }
|
|
80
|
+
|
|
81
|
+
# The parent node, or nil for the root.
|
|
82
|
+
def previous
|
|
83
|
+
@parent
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def [](idx)
|
|
87
|
+
children[idx]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Yields each mainline node from +self.next+ onward (the root is
|
|
91
|
+
# excluded), so +main_line.map(&:notation) == game.moves.map(&:notation)+
|
|
92
|
+
# and +main_line.map(&:position) == game.positions[1..]+. Returns an
|
|
93
|
+
# Enumerator when called without a block.
|
|
94
|
+
def main_line
|
|
95
|
+
return enum_for(:main_line) unless block_given?
|
|
96
|
+
|
|
97
|
+
node = self.next
|
|
98
|
+
while node
|
|
99
|
+
yield node
|
|
100
|
+
node = node.next
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The position reached at this node: the starting position for the
|
|
105
|
+
# root, otherwise +parent.position+ with +move.notation+ applied. Pure
|
|
106
|
+
# Ruby (no native engine); raises on an illegal SAN exactly like
|
|
107
|
+
# {PGN::Game#positions}. Cached on the node.
|
|
108
|
+
def position
|
|
109
|
+
return @position if defined?(@position)
|
|
110
|
+
|
|
111
|
+
@position = if root?
|
|
112
|
+
@starting_position
|
|
113
|
+
else
|
|
114
|
+
@parent.position.then { |p| p.move(@move.notation) }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Append a new variation line (a single SAN String or an Array<String>)
|
|
119
|
+
# branching before this node's next mainline move. Returns a fresh
|
|
120
|
+
# +game.root+. Raises +ArgumentError+ at a terminal node (a variation
|
|
121
|
+
# must branch before an existing move; use +add_main_variation+ to
|
|
122
|
+
# extend the line).
|
|
123
|
+
def add_variation(move_or_moves)
|
|
124
|
+
cont = continuation_movetext
|
|
125
|
+
raise ArgumentError, 'cannot add a variation at a terminal node' if cont.nil?
|
|
126
|
+
|
|
127
|
+
normalize_branch_point(cont)
|
|
128
|
+
cont.variations << build_movetexts(move_or_moves)
|
|
129
|
+
@game.root
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Make the given moves the new mainline continuation from this node's
|
|
133
|
+
# position. At a terminal node this extends the line; otherwise the old
|
|
134
|
+
# continuation becomes a variation of the new first move. Returns a
|
|
135
|
+
# fresh +game.root+.
|
|
136
|
+
def add_main_variation(move_or_moves)
|
|
137
|
+
new_line = build_movetexts(move_or_moves)
|
|
138
|
+
cont = continuation_movetext
|
|
139
|
+
|
|
140
|
+
if cont.nil?
|
|
141
|
+
@line.push(*new_line)
|
|
142
|
+
else
|
|
143
|
+
normalize_branch_point(cont)
|
|
144
|
+
vars = cont.variations
|
|
145
|
+
pos = @index + 1
|
|
146
|
+
old_tail = @line[(pos + 1)..] || []
|
|
147
|
+
n0 = new_line[0]
|
|
148
|
+
@line[pos] = n0
|
|
149
|
+
@line[(pos + 1)..] = (new_line[1..] || [])
|
|
150
|
+
cont.variations = []
|
|
151
|
+
n0.variations = [[cont, *old_tail], *vars]
|
|
152
|
+
end
|
|
153
|
+
@game.root
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Move this node one slot toward the mainline among its siblings.
|
|
157
|
+
# No-op for the mainline (index 0) or the first variation (index 1).
|
|
158
|
+
# Returns a fresh +game.root+.
|
|
159
|
+
def promote
|
|
160
|
+
return @game.root if root?
|
|
161
|
+
|
|
162
|
+
i = sibling_index
|
|
163
|
+
return @game.root if i.nil? || i <= 1
|
|
164
|
+
|
|
165
|
+
cont = parent_continuation
|
|
166
|
+
normalize_branch_point(cont)
|
|
167
|
+
vars = cont.variations
|
|
168
|
+
vars[i - 1], vars[i - 2] = vars[i - 2], vars[i - 1]
|
|
169
|
+
@game.root
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Move this node one slot toward the end among its siblings. At index 0
|
|
173
|
+
# (the mainline) this is the inverse swap: variation #1 becomes the new
|
|
174
|
+
# mainline and the old mainline becomes variation #1. No-op at the last
|
|
175
|
+
# index. Returns a fresh +game.root+.
|
|
176
|
+
def demote
|
|
177
|
+
return @game.root if root?
|
|
178
|
+
|
|
179
|
+
i = sibling_index
|
|
180
|
+
sibs = @parent.children
|
|
181
|
+
return @game.root if i.nil? || i == sibs.size - 1
|
|
182
|
+
|
|
183
|
+
cont = parent_continuation
|
|
184
|
+
normalize_branch_point(cont)
|
|
185
|
+
vars = cont.variations
|
|
186
|
+
if i.zero?
|
|
187
|
+
v1 = vars.shift
|
|
188
|
+
make_mainline(cont, v1, vars, old_main_position: :first)
|
|
189
|
+
else
|
|
190
|
+
vars[i - 1], vars[i] = vars[i], vars[i - 1]
|
|
191
|
+
end
|
|
192
|
+
@game.root
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Make this node the mainline at its branching point (the old mainline
|
|
196
|
+
# becomes variation #1). No-op if already the mainline. Returns a
|
|
197
|
+
# fresh +game.root+.
|
|
198
|
+
def promote_to_main
|
|
199
|
+
return @game.root if root?
|
|
200
|
+
|
|
201
|
+
i = sibling_index
|
|
202
|
+
return @game.root if i.nil? || i.zero?
|
|
203
|
+
|
|
204
|
+
cont = parent_continuation
|
|
205
|
+
normalize_branch_point(cont)
|
|
206
|
+
vars = cont.variations
|
|
207
|
+
vk = vars.delete_at(i - 1)
|
|
208
|
+
make_mainline(cont, vk, vars, old_main_position: :first)
|
|
209
|
+
@game.root
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Move this node to the last position among its siblings. At index 0
|
|
213
|
+
# the last variation becomes the new mainline and the old mainline
|
|
214
|
+
# becomes the last variation. Returns a fresh +game.root+.
|
|
215
|
+
def demote_to_last
|
|
216
|
+
return @game.root if root?
|
|
217
|
+
|
|
218
|
+
i = sibling_index
|
|
219
|
+
return @game.root if i.nil?
|
|
220
|
+
|
|
221
|
+
cont = parent_continuation
|
|
222
|
+
normalize_branch_point(cont)
|
|
223
|
+
vars = cont.variations
|
|
224
|
+
if i.zero?
|
|
225
|
+
return @game.root if vars.empty?
|
|
226
|
+
|
|
227
|
+
last = vars.pop
|
|
228
|
+
make_mainline(cont, last, vars, old_main_position: :last)
|
|
229
|
+
else
|
|
230
|
+
el = vars.delete_at(i - 1)
|
|
231
|
+
vars.push(el)
|
|
232
|
+
end
|
|
233
|
+
@game.root
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Remove this node and its subtree. If it is the mainline continuation,
|
|
237
|
+
# the first remaining variation (if any) takes its place; otherwise the
|
|
238
|
+
# line is truncated at this point. Returns a fresh +game.root+.
|
|
239
|
+
def delete
|
|
240
|
+
return @game.root if root?
|
|
241
|
+
|
|
242
|
+
i = sibling_index
|
|
243
|
+
return @game.root if i.nil?
|
|
244
|
+
|
|
245
|
+
cont = parent_continuation
|
|
246
|
+
normalize_branch_point(cont)
|
|
247
|
+
vars = cont.variations
|
|
248
|
+
if i.zero?
|
|
249
|
+
delete_mainline_continuation(cont, vars)
|
|
250
|
+
else
|
|
251
|
+
vars.delete_at(i - 1)
|
|
252
|
+
end
|
|
253
|
+
@game.root
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
private
|
|
257
|
+
|
|
258
|
+
# The MoveText played from this node's position in the current line:
|
|
259
|
+
# the next entry in +line+ (nil at a terminal position). For the root,
|
|
260
|
+
# +index+ is -1, so this is +line[0]+ (the first mainline move).
|
|
261
|
+
def continuation_movetext
|
|
262
|
+
@line && @line[@index + 1]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Yield every first-move branching at the position before +m+ (i.e. at
|
|
266
|
+
# this node's position): +m+ itself (in +l+ at +idx+), plus the first
|
|
267
|
+
# move of each of +m+'s variations, plus — recursively — the first
|
|
268
|
+
# moves of any variations nested on those first moves (they branch at
|
|
269
|
+
# the same point, since a variation branches before the move it
|
|
270
|
+
# attaches to).
|
|
271
|
+
def collect_first_moves(movetext, line, idx, &block)
|
|
272
|
+
return if movetext.nil?
|
|
273
|
+
|
|
274
|
+
block.call(movetext, line, idx)
|
|
275
|
+
(movetext.variations || []).each do |variation|
|
|
276
|
+
next unless variation && variation[0]
|
|
277
|
+
|
|
278
|
+
collect_first_moves(variation[0], variation, 0, &block)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Index of this node among its parent's children, or nil if root.
|
|
283
|
+
def sibling_index
|
|
284
|
+
@parent.children.index(self)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The MoveText that is the mainline continuation at the PARENT's
|
|
288
|
+
# position (the move this node is an alternative to, or this node
|
|
289
|
+
# itself if it is the continuation).
|
|
290
|
+
def parent_continuation
|
|
291
|
+
@parent.line[@parent.index + 1]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Build an Array<PGN::MoveText> from a single SAN String or an Array of
|
|
295
|
+
# SANs, applying the same castling 0->O normalization as Game#moves=.
|
|
296
|
+
def build_movetexts(move_or_moves)
|
|
297
|
+
sans = move_or_moves.is_a?(String) ? [move_or_moves] : move_or_moves
|
|
298
|
+
sans.map { |s| PGN::MoveText.new(normalize_castling(s)) }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def normalize_castling(san)
|
|
302
|
+
san.include?('0') ? san.gsub('0', 'O') : san
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Hoist every variation first-move branching at the position before
|
|
306
|
+
# +cont+ (recursively through nested brackets) into a single flat
|
|
307
|
+
# +cont.variations+ array, clearing the first-moves' at-point variations
|
|
308
|
+
# (they are now flat siblings). Internal structure of each variation
|
|
309
|
+
# line (tail moves and their deeper variations) is untouched. After
|
|
310
|
+
# this, +cont.variations+ is a flat Array<Array<MoveText>> in the same
|
|
311
|
+
# order +children+ yields.
|
|
312
|
+
def normalize_branch_point(cont)
|
|
313
|
+
return if cont.nil?
|
|
314
|
+
|
|
315
|
+
lines = []
|
|
316
|
+
collect_variation_lines(cont) { |v| lines << v }
|
|
317
|
+
lines.each { |v| v[0].variations = [] }
|
|
318
|
+
cont.variations = lines
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Yield each variation line branching at the position before +m+ (in
|
|
322
|
+
# DFS order): +m+'s direct variations, plus — recursively — the
|
|
323
|
+
# variations nested on each variation's first move (they branch at the
|
|
324
|
+
# same point).
|
|
325
|
+
def collect_variation_lines(movetext, &block)
|
|
326
|
+
(movetext.variations || []).each do |variation|
|
|
327
|
+
next unless variation && variation[0]
|
|
328
|
+
|
|
329
|
+
block.call(variation)
|
|
330
|
+
collect_variation_lines(variation[0], &block)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Make +vk+ (= [vk0, *tail]) the new mainline continuation at the
|
|
335
|
+
# parent's position, replacing +cont+. The old mainline (+cont+ and
|
|
336
|
+
# its tail) becomes a variation appended to +others+ either before
|
|
337
|
+
# (+old_main_position == :first+) or after (+:last+). +cont.variations+
|
|
338
|
+
# is cleared (its at-point variations are now +vk0+'s siblings).
|
|
339
|
+
def make_mainline(cont, variation, others, old_main_position:)
|
|
340
|
+
line = @parent.line
|
|
341
|
+
pos = @parent.index + 1
|
|
342
|
+
old_tail = line[(pos + 1)..] || []
|
|
343
|
+
variation0 = variation[0]
|
|
344
|
+
line[pos] = variation0
|
|
345
|
+
line[(pos + 1)..] = (variation[1..] || [])
|
|
346
|
+
cont.variations = []
|
|
347
|
+
old_var = [cont, *old_tail]
|
|
348
|
+
variation0.variations =
|
|
349
|
+
old_main_position == :first ? [old_var, *others] : [*others, old_var]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Replace the mainline continuation +cont+ with its first variation
|
|
353
|
+
# (+vars+ is +cont.variations+, flat). If there are no variations, the
|
|
354
|
+
# line is truncated at the continuation; otherwise the first variation
|
|
355
|
+
# takes the continuation's place and the remaining variations become
|
|
356
|
+
# its variations.
|
|
357
|
+
def delete_mainline_continuation(cont, vars)
|
|
358
|
+
line = @parent.line
|
|
359
|
+
pos = @parent.index + 1
|
|
360
|
+
if vars.empty?
|
|
361
|
+
line[pos..] = []
|
|
362
|
+
else
|
|
363
|
+
first_variation = vars.shift
|
|
364
|
+
first_variation_move = first_variation[0]
|
|
365
|
+
line[pos] = first_variation_move
|
|
366
|
+
line[(pos + 1)..] = (first_variation[1..] || [])
|
|
367
|
+
cont.variations = []
|
|
368
|
+
first_variation_move.variations = vars
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
data/lib/pgn/notation.rb
CHANGED
|
@@ -133,7 +133,7 @@ module PGN
|
|
|
133
133
|
# Other squares holding `piece` whose move to `to_idx` is legal.
|
|
134
134
|
def same_type_legals(piece, from_idx, to_idx)
|
|
135
135
|
(0...128).each_with_object([]) do |idx, acc|
|
|
136
|
-
next if (
|
|
136
|
+
next if idx.anybits?(0x88)
|
|
137
137
|
next if idx == from_idx
|
|
138
138
|
next unless @board.at_index(idx) == piece
|
|
139
139
|
|
|
@@ -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?
|
|
@@ -230,9 +230,9 @@ module PGN
|
|
|
230
230
|
|
|
231
231
|
case piece.upcase
|
|
232
232
|
when 'N'
|
|
233
|
-
|
|
233
|
+
Board::KNIGHT_ATTACKS[from_idx].include?(to_idx)
|
|
234
234
|
when 'K'
|
|
235
|
-
|
|
235
|
+
Board::KING_ATTACKS[from_idx].include?(to_idx)
|
|
236
236
|
when 'B', 'R', 'Q'
|
|
237
237
|
slider_reaches?(piece.upcase, from_idx, to_idx)
|
|
238
238
|
else
|
|
@@ -243,7 +243,7 @@ module PGN
|
|
|
243
243
|
def slider_reaches?(piece_up, from_idx, to_idx)
|
|
244
244
|
slider_dirs(piece_up).any? do |off|
|
|
245
245
|
i = from_idx + off
|
|
246
|
-
while (
|
|
246
|
+
while i.nobits?(0x88)
|
|
247
247
|
return true if i == to_idx
|
|
248
248
|
break if @board.at_index(i)
|
|
249
249
|
|
|
@@ -264,7 +264,7 @@ module PGN
|
|
|
264
264
|
end
|
|
265
265
|
|
|
266
266
|
def own?(piece, color)
|
|
267
|
-
color == 'w' ? piece
|
|
267
|
+
piece == (color == 'w' ? piece.upcase : piece.downcase)
|
|
268
268
|
end
|
|
269
269
|
|
|
270
270
|
# Slider ray directions for a piece letter ('B'/'R'/'Q').
|
|
@@ -278,71 +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 & 0x88) != 0
|
|
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 & 0x88).zero? && board.at_index(i) == pawn
|
|
305
|
-
end
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
def knight_attacked?(board, target, color)
|
|
309
|
-
knight = color == 'w' ? 'N' : 'n'
|
|
310
|
-
KNIGHT_OFFS.any? do |off|
|
|
311
|
-
i = target + off
|
|
312
|
-
(i & 0x88).zero? && board.at_index(i) == knight
|
|
313
|
-
end
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
def king_attacked?(board, target, color)
|
|
317
|
-
king = color == 'w' ? 'K' : 'k'
|
|
318
|
-
KING_OFFS.any? do |off|
|
|
319
|
-
i = target + off
|
|
320
|
-
(i & 0x88).zero? && board.at_index(i) == king
|
|
321
|
-
end
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
def slider_attacked?(board, target, color)
|
|
325
|
-
bishop = color == 'w' ? 'B' : 'b'
|
|
326
|
-
rook = color == 'w' ? 'R' : 'r'
|
|
327
|
-
queen = color == 'w' ? 'Q' : 'q'
|
|
328
|
-
ray_hit?(board, target, BISHOP_DIRS) { |p| p == bishop || p == queen } ||
|
|
329
|
-
ray_hit?(board, target, ROOK_DIRS) { |p| p == rook || p == queen }
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
def ray_hit?(board, target, dirs)
|
|
333
|
-
dirs.each do |off|
|
|
334
|
-
i = target + off
|
|
335
|
-
while (i & 0x88).zero?
|
|
336
|
-
piece = board.at_index(i)
|
|
337
|
-
if piece
|
|
338
|
-
return true if yield(piece)
|
|
339
|
-
break
|
|
340
|
-
end
|
|
341
|
-
i += off
|
|
342
|
-
end
|
|
343
|
-
end
|
|
344
|
-
false
|
|
345
|
-
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}.)
|
|
346
283
|
|
|
347
284
|
# -- legal-move generation (for checkmate detection) ------------------
|
|
348
285
|
|
|
@@ -352,7 +289,7 @@ module PGN
|
|
|
352
289
|
ep_idx = ep_target_idx(position, board)
|
|
353
290
|
|
|
354
291
|
(0...128).any? do |idx|
|
|
355
|
-
next if (
|
|
292
|
+
next if idx.anybits?(0x88)
|
|
356
293
|
|
|
357
294
|
piece = board.at_index(idx)
|
|
358
295
|
piece && own?(piece, color) && move_from?(board, idx, piece, color, ep_idx)
|
|
@@ -370,8 +307,8 @@ module PGN
|
|
|
370
307
|
def move_from?(board, idx, piece, color, ep_idx)
|
|
371
308
|
up = piece.upcase
|
|
372
309
|
case up
|
|
373
|
-
when 'N' then leaper_moves?(board, idx, piece, color,
|
|
374
|
-
when 'K' then leaper_moves?(board, idx, piece, color,
|
|
310
|
+
when 'N' then leaper_moves?(board, idx, piece, color, Board::KNIGHT_ATTACKS[idx])
|
|
311
|
+
when 'K' then leaper_moves?(board, idx, piece, color, Board::KING_ATTACKS[idx])
|
|
375
312
|
when 'B', 'R', 'Q'
|
|
376
313
|
slider_moves?(board, idx, piece, color, slider_dirs(up))
|
|
377
314
|
when 'P'
|
|
@@ -381,22 +318,22 @@ module PGN
|
|
|
381
318
|
end
|
|
382
319
|
end
|
|
383
320
|
|
|
384
|
-
def leaper_moves?(board, idx, piece, color,
|
|
385
|
-
|
|
386
|
-
t
|
|
387
|
-
(t & 0x88).zero? && landable?(board, t, color) && safe?(board, idx, t, piece, nil)
|
|
321
|
+
def leaper_moves?(board, idx, piece, color, targets)
|
|
322
|
+
targets.any? do |t|
|
|
323
|
+
landable?(board, t, color) && safe?(board, idx, t, piece, nil)
|
|
388
324
|
end
|
|
389
325
|
end
|
|
390
326
|
|
|
391
327
|
def slider_moves?(board, idx, piece, color, dirs)
|
|
392
328
|
dirs.any? do |off|
|
|
393
329
|
t = idx + off
|
|
394
|
-
while (
|
|
330
|
+
while t.nobits?(0x88)
|
|
395
331
|
tp = board.at_index(t)
|
|
396
332
|
if tp.nil?
|
|
397
333
|
return true if safe?(board, idx, t, piece, nil)
|
|
398
334
|
else
|
|
399
335
|
return true if !own?(tp, color) && safe?(board, idx, t, piece, nil)
|
|
336
|
+
|
|
400
337
|
break
|
|
401
338
|
end
|
|
402
339
|
t += off
|
|
@@ -416,24 +353,24 @@ module PGN
|
|
|
416
353
|
promo_rank = color == 'w' ? 7 : 0
|
|
417
354
|
|
|
418
355
|
t = idx + dir
|
|
419
|
-
return false unless (
|
|
356
|
+
return false unless t.nobits?(0x88) && board.at_index(t).nil?
|
|
420
357
|
|
|
421
|
-
promo =
|
|
358
|
+
promo = t >> 4 == promo_rank ? 'Q' : nil
|
|
422
359
|
return true if safe?(board, idx, t, piece, promo)
|
|
423
360
|
|
|
424
361
|
t2 = idx + (dir * 2)
|
|
425
362
|
(idx >> 4) == start_rank && board.at_index(t2).nil? && safe?(board, idx, t2, piece, nil)
|
|
426
363
|
end
|
|
427
364
|
|
|
428
|
-
def pawn_captures?(board, idx, piece, color, ep_idx)
|
|
365
|
+
def pawn_captures?(board, idx, piece, color, ep_idx)
|
|
429
366
|
promo_rank = color == 'w' ? 7 : 0
|
|
430
367
|
caps = color == 'w' ? [15, 17] : [-15, -17]
|
|
431
368
|
caps.any? do |off|
|
|
432
369
|
t = idx + off
|
|
433
|
-
next false unless (
|
|
370
|
+
next false unless t.nobits?(0x88)
|
|
434
371
|
|
|
435
372
|
tp = board.at_index(t)
|
|
436
|
-
promo =
|
|
373
|
+
promo = t >> 4 == promo_rank ? 'Q' : nil
|
|
437
374
|
if tp && !own?(tp, color)
|
|
438
375
|
safe?(board, idx, t, piece, promo)
|
|
439
376
|
elsif tp.nil? && t == ep_idx
|
|
@@ -459,8 +396,8 @@ module PGN
|
|
|
459
396
|
nb.update_index(from_idx, nil)
|
|
460
397
|
nb.update_index(to_idx, place_piece(piece, promotion))
|
|
461
398
|
color = own?(piece, 'w') ? 'w' : 'b'
|
|
462
|
-
king = king_idx(nb, color)
|
|
463
|
-
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')
|
|
464
401
|
end
|
|
465
402
|
|
|
466
403
|
# The piece letter to place on the destination after a pseudo-move.
|