pgn2 2.0.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/release-gems.yml +5 -2
- data/CHANGELOG.md +69 -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/lib/pgn/attack.rb +97 -0
- data/lib/pgn/epd.rb +81 -0
- data/lib/pgn/fen.rb +8 -1
- data/lib/pgn/game.rb +84 -5
- data/lib/pgn/move.rb +4 -4
- data/lib/pgn/node.rb +372 -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 +191 -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 +60 -0
- data/spec/movetext_clean_spec.rb +44 -0
- data/spec/node_spec.rb +240 -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
- metadata +18 -2
data/lib/pgn/game.rb
CHANGED
|
@@ -32,7 +32,12 @@ module PGN
|
|
|
32
32
|
def clean_text(text)
|
|
33
33
|
return unless text
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
# Strip the single outermost brace pair, then unescape \{ \} \\ so
|
|
36
|
+
# the internal representation is the raw comment body. {Serializer}
|
|
37
|
+
# re-escapes on output, so parse -> serialize -> parse is byte-stable.
|
|
38
|
+
text = text[1..-2] if text.start_with?('{') && text.end_with?('}')
|
|
39
|
+
text = text.gsub(/\\([\\{}])/, '\1')
|
|
40
|
+
text.gsub(/\s+/, ' ').strip
|
|
36
41
|
end
|
|
37
42
|
end
|
|
38
43
|
|
|
@@ -135,6 +140,70 @@ module PGN
|
|
|
135
140
|
positions.map { |p| p.to_fen.inspect }
|
|
136
141
|
end
|
|
137
142
|
|
|
143
|
+
# The current {PGN::Position} (the last position after replaying all
|
|
144
|
+
# moves), or the starting position when there are no moves.
|
|
145
|
+
#
|
|
146
|
+
# @return [PGN::Position]
|
|
147
|
+
def current_position
|
|
148
|
+
positions.last
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Append a move in SAN, validating legality when the native engine is
|
|
152
|
+
# available. Raises ArgumentError for an illegal move (when the engine is
|
|
153
|
+
# loaded) and invalidates the memoized position list.
|
|
154
|
+
#
|
|
155
|
+
# @param san [String, PGN::MoveText] the move to append
|
|
156
|
+
# @return [self]
|
|
157
|
+
def push(san)
|
|
158
|
+
move = standardize_castling(san)
|
|
159
|
+
if PGN::Bitboard.const_defined?(:Engine, false) && !current_position.legal?(move.notation)
|
|
160
|
+
raise ArgumentError, "illegal move: #{san}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
@moves << move
|
|
164
|
+
@positions = nil
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Remove and return the last move, or nil if there are none. Invalidates
|
|
169
|
+
# the memoized position list.
|
|
170
|
+
#
|
|
171
|
+
# @return [PGN::MoveText, nil]
|
|
172
|
+
def pop
|
|
173
|
+
return nil if @moves.empty?
|
|
174
|
+
|
|
175
|
+
move = @moves.pop
|
|
176
|
+
@positions = nil
|
|
177
|
+
move
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Whether any position has occurred three times in this game (the
|
|
181
|
+
# threefold-repetition draw). Uses {PGN::Position#hash} (the Zobrist
|
|
182
|
+
# hash of the FEN-relevant state), streaming {#each_position} so the
|
|
183
|
+
# full position array need not be materialized for this check.
|
|
184
|
+
#
|
|
185
|
+
# @return [Boolean]
|
|
186
|
+
def threefold?
|
|
187
|
+
counts = Hash.new(0)
|
|
188
|
+
each_position { |position| counts[position.hash] += 1 }
|
|
189
|
+
counts.any? { |_, count| count >= 3 }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The terminal status of the game: :checkmate, :stalemate, or :draw
|
|
193
|
+
# (insufficient material, 50-move rule, or threefold repetition). nil
|
|
194
|
+
# if the game is still in progress. Requires the native extension for
|
|
195
|
+
# checkmate/stalemate detection.
|
|
196
|
+
#
|
|
197
|
+
# @return [Symbol, nil]
|
|
198
|
+
def outcome
|
|
199
|
+
final = positions.last
|
|
200
|
+
result = final&.outcome
|
|
201
|
+
return result if result
|
|
202
|
+
return :draw if threefold?
|
|
203
|
+
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
138
207
|
# Interactively step through the game
|
|
139
208
|
#
|
|
140
209
|
# Use +d+ to move forward, +a+ to move backward, and +^C+ to exit.
|
|
@@ -159,17 +228,27 @@ module PGN
|
|
|
159
228
|
end
|
|
160
229
|
end
|
|
161
230
|
|
|
231
|
+
# Build a fresh, navigable {PGN::Node} tree over the mainline. The tree
|
|
232
|
+
# is a live view of the underlying +MoveText+ structure; mutate it
|
|
233
|
+
# through the node API, then call +#root+ again for a fresh tree.
|
|
234
|
+
def root
|
|
235
|
+
PGN::Node.new(
|
|
236
|
+
move: nil, parent: nil, line: @moves, index: -1,
|
|
237
|
+
starting_position: starting_position, game: self
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
162
241
|
private
|
|
163
242
|
|
|
164
243
|
# A MoveText is reused as-is (no new object) when its notation needs no
|
|
165
|
-
# '0'->'O' fix
|
|
166
|
-
#
|
|
167
|
-
#
|
|
244
|
+
# '0'->'O' fix; otherwise a new MoveText is built. clean_text is idempotent
|
|
245
|
+
# (it only strips a *single* outermost brace pair), so reusing or rebuilding
|
|
246
|
+
# a MoveText never corrupts a comment that still contains inner braces.
|
|
168
247
|
def standardize_castling(entry)
|
|
169
248
|
return MoveText.new(entry.include?('0') ? entry.gsub('0', 'O') : entry) if entry.is_a?(String)
|
|
170
249
|
|
|
171
250
|
notation = entry.notation.include?('0') ? entry.notation.gsub('0', 'O') : entry.notation
|
|
172
|
-
return entry if notation.equal?(entry.notation)
|
|
251
|
+
return entry if notation.equal?(entry.notation)
|
|
173
252
|
|
|
174
253
|
MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
|
|
175
254
|
end
|
data/lib/pgn/move.rb
CHANGED
|
@@ -74,7 +74,7 @@ module PGN
|
|
|
74
74
|
(?<capture> x ){0}
|
|
75
75
|
(?<disambiguation> [a-h]?[1-8]? ){0}
|
|
76
76
|
|
|
77
|
-
(?<castle>
|
|
77
|
+
(?<castle> [O0]-[O0](?:-[O0])? ){0}
|
|
78
78
|
|
|
79
79
|
(?<normal>
|
|
80
80
|
\g<piece>?
|
|
@@ -112,7 +112,7 @@ module PGN
|
|
|
112
112
|
# Castling SAN (O-O / O-O-O) has no piece attribute; the castle attribute
|
|
113
113
|
# is set later in #initialize. Use a non-allocating prefix check instead
|
|
114
114
|
# of `san.match('O-O')`, which allocated a MatchData on every Move.new.
|
|
115
|
-
return if san.start_with?('O')
|
|
115
|
+
return if san.start_with?('O') || san.start_with?('0')
|
|
116
116
|
|
|
117
117
|
val ||= 'P'
|
|
118
118
|
@piece = black? ? val.downcase : val
|
|
@@ -136,8 +136,8 @@ module PGN
|
|
|
136
136
|
def castle=(val)
|
|
137
137
|
return unless val
|
|
138
138
|
|
|
139
|
-
@castle = 'K' if
|
|
140
|
-
@castle = 'Q' if
|
|
139
|
+
@castle = 'K' if %w[O-O 0-0].include?(val)
|
|
140
|
+
@castle = 'Q' if %w[O-O-O 0-0-0].include?(val)
|
|
141
141
|
@castle.downcase! if black?
|
|
142
142
|
end
|
|
143
143
|
|
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
|
@@ -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.
|