pgn2 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/release-gems.yml +5 -2
- data/CHANGELOG.md +101 -2
- data/README.md +27 -0
- data/Rakefile +18 -0
- data/TODO.md +25 -107
- data/docs/superpowers/plans/2026-08-15-game-tree-api-plan.md +1014 -0
- data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -0
- data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
- data/ext/pgn2_native/pgn2-bitboard/src/board.rs +2 -10
- data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +0 -2
- data/ext/pgn2_native/pgn2_native/src/lib.rs +5 -5
- data/lib/pgn/attack.rb +107 -0
- data/lib/pgn/epd.rb +54 -0
- data/lib/pgn/fen.rb +54 -44
- data/lib/pgn/game.rb +91 -7
- data/lib/pgn/move.rb +4 -4
- data/lib/pgn/node.rb +347 -0
- data/lib/pgn/notation.rb +8 -66
- data/lib/pgn/pgn_parser.rb +30 -31
- data/lib/pgn/pgn_parser.y +14 -15
- data/lib/pgn/position.rb +193 -0
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +3 -0
- data/spec/castling_normalization_spec.rb +39 -0
- data/spec/comment_round_trip_spec.rb +35 -0
- data/spec/epd_spec.rb +44 -0
- data/spec/fen_spec.rb +6 -0
- data/spec/game_history_spec.rb +46 -0
- data/spec/game_spec.rb +46 -0
- data/spec/movetext_clean_spec.rb +44 -0
- data/spec/node_spec.rb +222 -0
- data/spec/outcome_spec.rb +93 -0
- data/spec/parser_left_recursion_spec.rb +37 -0
- data/spec/position_attack_spec.rb +56 -0
- data/spec/position_legal_spec.rb +123 -0
- data/spec/spec_helper.rb +17 -0
- metadata +18 -3
- data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +0 -121
data/lib/pgn/fen.rb
CHANGED
|
@@ -1,4 +1,44 @@
|
|
|
1
1
|
module PGN
|
|
2
|
+
# Shared FEN/EPD board-field parsing and serialization: the piece-placement
|
|
3
|
+
# field, and the '-'-normalized castling/en-passant fields. {PGN::FEN} and
|
|
4
|
+
# {PGN::EPD} both include this rather than each carrying their own copy.
|
|
5
|
+
module PositionFields
|
|
6
|
+
def castling=(val)
|
|
7
|
+
@castling = val.nil? || val.empty? ? '-' : val
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def en_passant=(val)
|
|
11
|
+
@en_passant = val.nil? ? '-' : val
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param board_fen [String] the FEN/EPD representation of the board
|
|
15
|
+
def board_string=(board_fen)
|
|
16
|
+
squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
|
|
17
|
+
.split('/')
|
|
18
|
+
.map(&:chars)
|
|
19
|
+
.map { |row| row.map { |e| e == '_' ? nil : e } }
|
|
20
|
+
.reverse
|
|
21
|
+
.transpose
|
|
22
|
+
self.board = PGN::Board.new(squares)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [String] the FEN/EPD board-string portion
|
|
26
|
+
def board_string
|
|
27
|
+
board.fen_board_string
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# The player/castling-rights/en-passant args shared by FEN#to_position
|
|
33
|
+
# and EPD#to_position (which differ only in halfmove/fullmove).
|
|
34
|
+
def position_fields
|
|
35
|
+
player = active == 'w' ? :white : :black
|
|
36
|
+
castling_rights = castling.chars - ['-']
|
|
37
|
+
ep = en_passant == '-' ? nil : en_passant
|
|
38
|
+
[player, castling_rights, ep]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
2
42
|
# {PGN::FEN} is responsible for translating between strings in FEN
|
|
3
43
|
# notation and an internal representation of the board.
|
|
4
44
|
#
|
|
@@ -35,14 +75,17 @@ module PGN
|
|
|
35
75
|
# plays.
|
|
36
76
|
#
|
|
37
77
|
class FEN
|
|
78
|
+
include PositionFields
|
|
79
|
+
|
|
38
80
|
# The FEN string representing the starting position in chess
|
|
39
81
|
#
|
|
40
82
|
INITIAL = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'.freeze
|
|
41
83
|
|
|
42
84
|
attr_accessor :board, :active, :halfmove, :fullmove
|
|
43
|
-
# `castling` and `en_passant` have custom writers
|
|
44
|
-
# to '-'), so expose readers here and
|
|
45
|
-
# redefining the accessors generated by
|
|
85
|
+
# `castling` and `en_passant` have custom writers in {PositionFields}
|
|
86
|
+
# (normalize nil/empty to '-'), so expose readers here and rely on the
|
|
87
|
+
# module for the writers, to avoid redefining the accessors generated by
|
|
88
|
+
# attr_accessor (Lint/DuplicateMethods).
|
|
46
89
|
attr_reader :castling, :en_passant
|
|
47
90
|
|
|
48
91
|
# @return [PGN::FEN] a {PGN::FEN} object representing the starting
|
|
@@ -75,52 +118,19 @@ module PGN
|
|
|
75
118
|
self.fullmove = fen_string.split
|
|
76
119
|
end
|
|
77
120
|
|
|
78
|
-
def en_passant=(val)
|
|
79
|
-
@en_passant = val.nil? ? '-' : val
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def castling=(val)
|
|
83
|
-
@castling = val.nil? || val.empty? ? '-' : val
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# @param board_fen [String] the fen representation of the board
|
|
87
|
-
# @example
|
|
88
|
-
# fen.board_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
89
|
-
#
|
|
90
|
-
def board_string=(board_fen)
|
|
91
|
-
squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
|
|
92
|
-
.split('/')
|
|
93
|
-
.map(&:chars)
|
|
94
|
-
.map { |row| row.map { |e| e == '_' ? nil : e } }
|
|
95
|
-
.reverse
|
|
96
|
-
.transpose
|
|
97
|
-
self.board = PGN::Board.new(squares)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# @return [String] the fen representation of the board
|
|
101
|
-
# @example
|
|
102
|
-
# PGN::FEN.start.board_string #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
103
|
-
#
|
|
104
|
-
def board_string
|
|
105
|
-
board.fen_board_string
|
|
106
|
-
end
|
|
107
|
-
|
|
108
121
|
# @return [PGN::Position] a {PGN::Position} representing the current
|
|
109
122
|
# position
|
|
110
123
|
#
|
|
111
124
|
def to_position
|
|
112
|
-
player
|
|
113
|
-
|
|
114
|
-
|
|
125
|
+
player, castling_rights, ep = position_fields
|
|
126
|
+
PGN::Position.new(board, player, castling_rights, ep, halfmove.to_i, fullmove.to_i)
|
|
127
|
+
end
|
|
115
128
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
halfmove.to_i,
|
|
122
|
-
fullmove.to_i
|
|
123
|
-
)
|
|
129
|
+
# @return [String] the EPD string for this position (the first four FEN
|
|
130
|
+
# fields, dropping the halfmove/fullmove counters)
|
|
131
|
+
#
|
|
132
|
+
def to_epd
|
|
133
|
+
PGN::EPD.new("#{board_string} #{active} #{castling} #{en_passant}").to_s
|
|
124
134
|
end
|
|
125
135
|
|
|
126
136
|
# @return [String] the FEN string
|
data/lib/pgn/game.rb
CHANGED
|
@@ -29,10 +29,20 @@ module PGN
|
|
|
29
29
|
@notation
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Normalize UCI-style castling ('0-0') to canonical SAN ('O-O').
|
|
33
|
+
def self.normalize_castling(san)
|
|
34
|
+
san.include?('0') ? san.gsub('0', 'O') : san
|
|
35
|
+
end
|
|
36
|
+
|
|
32
37
|
def clean_text(text)
|
|
33
38
|
return unless text
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
# Strip the single outermost brace pair, then unescape \{ \} \\ so
|
|
41
|
+
# the internal representation is the raw comment body. {Serializer}
|
|
42
|
+
# re-escapes on output, so parse -> serialize -> parse is byte-stable.
|
|
43
|
+
text = text[1..-2] if text.start_with?('{') && text.end_with?('}')
|
|
44
|
+
text = text.gsub(/\\([\\{}])/, '\1')
|
|
45
|
+
text.gsub(/\s+/, ' ').strip
|
|
36
46
|
end
|
|
37
47
|
end
|
|
38
48
|
|
|
@@ -135,6 +145,70 @@ module PGN
|
|
|
135
145
|
positions.map { |p| p.to_fen.inspect }
|
|
136
146
|
end
|
|
137
147
|
|
|
148
|
+
# The current {PGN::Position} (the last position after replaying all
|
|
149
|
+
# moves), or the starting position when there are no moves.
|
|
150
|
+
#
|
|
151
|
+
# @return [PGN::Position]
|
|
152
|
+
def current_position
|
|
153
|
+
positions.last
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Append a move in SAN, validating legality when the native engine is
|
|
157
|
+
# available. Raises ArgumentError for an illegal move (when the engine is
|
|
158
|
+
# loaded). Grows the memoized position list in step, if it is populated.
|
|
159
|
+
#
|
|
160
|
+
# @param san [String, PGN::MoveText] the move to append
|
|
161
|
+
# @return [self]
|
|
162
|
+
def push(san)
|
|
163
|
+
move = standardize_castling(san)
|
|
164
|
+
if PGN::Bitboard.const_defined?(:Engine, false) && !current_position.legal?(move.notation)
|
|
165
|
+
raise ArgumentError, "illegal move: #{san}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
@moves << move
|
|
169
|
+
@positions << @positions.last.move(move.notation) if @positions
|
|
170
|
+
self
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Remove and return the last move, or nil if there are none. Shrinks the
|
|
174
|
+
# memoized position list in step, if it is populated.
|
|
175
|
+
#
|
|
176
|
+
# @return [PGN::MoveText, nil]
|
|
177
|
+
def pop
|
|
178
|
+
return nil if @moves.empty?
|
|
179
|
+
|
|
180
|
+
move = @moves.pop
|
|
181
|
+
@positions&.pop
|
|
182
|
+
move
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Whether any position has occurred three times in this game (the
|
|
186
|
+
# threefold-repetition draw). Uses {PGN::Position#hash} (the Zobrist
|
|
187
|
+
# hash of the FEN-relevant state) over {#positions}, so a prior or
|
|
188
|
+
# subsequent call that also needs the position list shares the replay.
|
|
189
|
+
#
|
|
190
|
+
# @return [Boolean]
|
|
191
|
+
def threefold?
|
|
192
|
+
counts = Hash.new(0)
|
|
193
|
+
positions.each { |position| counts[position.hash] += 1 }
|
|
194
|
+
counts.any? { |_, count| count >= 3 }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The terminal status of the game: :checkmate, :stalemate, or :draw
|
|
198
|
+
# (insufficient material, 50-move rule, or threefold repetition). nil
|
|
199
|
+
# if the game is still in progress. Requires the native extension for
|
|
200
|
+
# checkmate/stalemate detection.
|
|
201
|
+
#
|
|
202
|
+
# @return [Symbol, nil]
|
|
203
|
+
def outcome
|
|
204
|
+
final = positions.last
|
|
205
|
+
result = final&.outcome
|
|
206
|
+
return result if result
|
|
207
|
+
return :draw if threefold?
|
|
208
|
+
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
138
212
|
# Interactively step through the game
|
|
139
213
|
#
|
|
140
214
|
# Use +d+ to move forward, +a+ to move backward, and +^C+ to exit.
|
|
@@ -159,17 +233,27 @@ module PGN
|
|
|
159
233
|
end
|
|
160
234
|
end
|
|
161
235
|
|
|
236
|
+
# Build a fresh, navigable {PGN::Node} tree over the mainline. The tree
|
|
237
|
+
# is a live view of the underlying +MoveText+ structure; mutate it
|
|
238
|
+
# through the node API, then call +#root+ again for a fresh tree.
|
|
239
|
+
def root
|
|
240
|
+
PGN::Node.new(
|
|
241
|
+
move: nil, parent: nil, line: @moves, index: -1,
|
|
242
|
+
starting_position: starting_position, game: self
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
162
246
|
private
|
|
163
247
|
|
|
164
248
|
# A MoveText is reused as-is (no new object) when its notation needs no
|
|
165
|
-
# '0'->'O' fix
|
|
166
|
-
#
|
|
167
|
-
#
|
|
249
|
+
# '0'->'O' fix; otherwise a new MoveText is built. clean_text is idempotent
|
|
250
|
+
# (it only strips a *single* outermost brace pair), so reusing or rebuilding
|
|
251
|
+
# a MoveText never corrupts a comment that still contains inner braces.
|
|
168
252
|
def standardize_castling(entry)
|
|
169
|
-
return MoveText.new(
|
|
253
|
+
return MoveText.new(MoveText.normalize_castling(entry)) if entry.is_a?(String)
|
|
170
254
|
|
|
171
|
-
notation =
|
|
172
|
-
return entry if notation.equal?(entry.notation)
|
|
255
|
+
notation = MoveText.normalize_castling(entry.notation)
|
|
256
|
+
return entry if notation.equal?(entry.notation)
|
|
173
257
|
|
|
174
258
|
MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
|
|
175
259
|
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,347 @@
|
|
|
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
|
+
make_mainline(@line, @index + 1, cont, new_line, cont.variations, old_main_position: :first)
|
|
145
|
+
end
|
|
146
|
+
@game.root
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Move this node one slot toward the mainline among its siblings.
|
|
150
|
+
# No-op for the mainline (index 0) or the first variation (index 1).
|
|
151
|
+
# Returns a fresh +game.root+.
|
|
152
|
+
def promote
|
|
153
|
+
mutate_sibling(noop: ->(i) { i <= 1 }) do |i, _cont, vars|
|
|
154
|
+
vars[i - 1], vars[i - 2] = vars[i - 2], vars[i - 1]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Move this node one slot toward the end among its siblings. At index 0
|
|
159
|
+
# (the mainline) this is the inverse swap: variation #1 becomes the new
|
|
160
|
+
# mainline and the old mainline becomes variation #1. No-op at the last
|
|
161
|
+
# index. Returns a fresh +game.root+.
|
|
162
|
+
def demote
|
|
163
|
+
mutate_sibling(noop: ->(i) { i == @parent.children.size - 1 }) do |i, cont, vars|
|
|
164
|
+
if i.zero?
|
|
165
|
+
v1 = vars.shift
|
|
166
|
+
make_mainline(@parent.line, @parent.index + 1, cont, v1, vars, old_main_position: :first)
|
|
167
|
+
else
|
|
168
|
+
vars[i - 1], vars[i] = vars[i], vars[i - 1]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Make this node the mainline at its branching point (the old mainline
|
|
174
|
+
# becomes variation #1). No-op if already the mainline. Returns a
|
|
175
|
+
# fresh +game.root+.
|
|
176
|
+
def promote_to_main
|
|
177
|
+
mutate_sibling(noop: lambda(&:zero?)) do |i, cont, vars|
|
|
178
|
+
vk = vars.delete_at(i - 1)
|
|
179
|
+
make_mainline(@parent.line, @parent.index + 1, cont, vk, vars, old_main_position: :first)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Move this node to the last position among its siblings. At index 0
|
|
184
|
+
# the last variation becomes the new mainline and the old mainline
|
|
185
|
+
# becomes the last variation. Returns a fresh +game.root+.
|
|
186
|
+
def demote_to_last
|
|
187
|
+
mutate_sibling do |i, cont, vars|
|
|
188
|
+
if i.zero?
|
|
189
|
+
unless vars.empty?
|
|
190
|
+
last = vars.pop
|
|
191
|
+
make_mainline(@parent.line, @parent.index + 1, cont, last, vars, old_main_position: :last)
|
|
192
|
+
end
|
|
193
|
+
else
|
|
194
|
+
el = vars.delete_at(i - 1)
|
|
195
|
+
vars.push(el)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Remove this node and its subtree. If it is the mainline continuation,
|
|
201
|
+
# the first remaining variation (if any) takes its place; otherwise the
|
|
202
|
+
# line is truncated at this point. Returns a fresh +game.root+.
|
|
203
|
+
def delete
|
|
204
|
+
mutate_sibling do |i, cont, vars|
|
|
205
|
+
if i.zero?
|
|
206
|
+
delete_mainline_continuation(cont, vars)
|
|
207
|
+
else
|
|
208
|
+
vars.delete_at(i - 1)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
# The MoveText played from this node's position in the current line:
|
|
216
|
+
# the next entry in +line+ (nil at a terminal position). For the root,
|
|
217
|
+
# +index+ is -1, so this is +line[0]+ (the first mainline move).
|
|
218
|
+
def continuation_movetext
|
|
219
|
+
@line && @line[@index + 1]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Yield every first-move branching at the position before +m+ (i.e. at
|
|
223
|
+
# this node's position): +m+ itself (in +l+ at +idx+), plus the first
|
|
224
|
+
# move of each of +m+'s variations, plus — recursively — the first
|
|
225
|
+
# moves of any variations nested on those first moves (they branch at
|
|
226
|
+
# the same point, since a variation branches before the move it
|
|
227
|
+
# attaches to).
|
|
228
|
+
def collect_first_moves(movetext, line, idx, &block)
|
|
229
|
+
return if movetext.nil?
|
|
230
|
+
|
|
231
|
+
block.call(movetext, line, idx)
|
|
232
|
+
(movetext.variations || []).each do |variation|
|
|
233
|
+
next unless variation && variation[0]
|
|
234
|
+
|
|
235
|
+
collect_first_moves(variation[0], variation, 0, &block)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Index of this node among its parent's children, or nil if root.
|
|
240
|
+
def sibling_index
|
|
241
|
+
@parent.children.index(self)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# The MoveText that is the mainline continuation at the PARENT's
|
|
245
|
+
# position (the move this node is an alternative to, or this node
|
|
246
|
+
# itself if it is the continuation).
|
|
247
|
+
def parent_continuation
|
|
248
|
+
@parent.line[@parent.index + 1]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Build an Array<PGN::MoveText> from a single SAN String or an Array of
|
|
252
|
+
# SANs, applying the same castling 0->O normalization as Game#moves=.
|
|
253
|
+
def build_movetexts(move_or_moves)
|
|
254
|
+
sans = move_or_moves.is_a?(String) ? [move_or_moves] : move_or_moves
|
|
255
|
+
sans.map { |s| PGN::MoveText.new(PGN::MoveText.normalize_castling(s)) }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Shared prologue/epilogue for the sibling-reordering mutators (promote,
|
|
259
|
+
# demote, promote_to_main, demote_to_last, delete): no-op at the root or
|
|
260
|
+
# per +noop+ (checked against the sibling index before any mutation),
|
|
261
|
+
# otherwise normalize the branching point and yield the sibling index,
|
|
262
|
+
# the parent's mainline continuation, and its (now flat) variations to
|
|
263
|
+
# +block+ for the mutation proper. Always returns a fresh +game.root+.
|
|
264
|
+
def mutate_sibling(noop: nil)
|
|
265
|
+
return @game.root if root?
|
|
266
|
+
|
|
267
|
+
i = sibling_index
|
|
268
|
+
return @game.root if i.nil? || noop&.call(i)
|
|
269
|
+
|
|
270
|
+
cont = parent_continuation
|
|
271
|
+
normalize_branch_point(cont)
|
|
272
|
+
yield i, cont, cont.variations
|
|
273
|
+
@game.root
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Hoist every variation first-move branching at the position before
|
|
277
|
+
# +cont+ (recursively through nested brackets) into a single flat
|
|
278
|
+
# +cont.variations+ array, clearing the first-moves' at-point variations
|
|
279
|
+
# (they are now flat siblings). Internal structure of each variation
|
|
280
|
+
# line (tail moves and their deeper variations) is untouched. After
|
|
281
|
+
# this, +cont.variations+ is a flat Array<Array<MoveText>> in the same
|
|
282
|
+
# order +children+ yields.
|
|
283
|
+
def normalize_branch_point(cont)
|
|
284
|
+
return if cont.nil?
|
|
285
|
+
|
|
286
|
+
lines = []
|
|
287
|
+
collect_variation_lines(cont) { |v| lines << v }
|
|
288
|
+
lines.each { |v| v[0].variations = [] }
|
|
289
|
+
cont.variations = lines
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Yield each variation line branching at the position before +m+ (in
|
|
293
|
+
# DFS order): +m+'s direct variations, plus — recursively — the
|
|
294
|
+
# variations nested on each variation's first move (they branch at the
|
|
295
|
+
# same point).
|
|
296
|
+
def collect_variation_lines(movetext, &block)
|
|
297
|
+
(movetext.variations || []).each do |variation|
|
|
298
|
+
next unless variation && variation[0]
|
|
299
|
+
|
|
300
|
+
block.call(variation)
|
|
301
|
+
collect_variation_lines(variation[0], &block)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Splice +movetexts+ (an Array<MoveText>) into +line+ starting at +pos+:
|
|
306
|
+
# its first entry replaces +line[pos]+, and the rest replace the tail
|
|
307
|
+
# after it (so a shorter +movetexts+ truncates +line+).
|
|
308
|
+
def splice_line!(line, pos, movetexts)
|
|
309
|
+
line[pos] = movetexts[0]
|
|
310
|
+
line[(pos + 1)..] = (movetexts[1..] || [])
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Make +vk+ (= [vk0, *tail]) the new mainline continuation at +line+/+pos+
|
|
314
|
+
# (the position before +cont+), replacing +cont+. The old mainline
|
|
315
|
+
# (+cont+ and its tail) becomes a variation appended to +others+ either
|
|
316
|
+
# before (+old_main_position == :first+) or after (+:last+).
|
|
317
|
+
# +cont.variations+ is cleared (its at-point variations are now +vk0+'s
|
|
318
|
+
# siblings).
|
|
319
|
+
def make_mainline(line, pos, cont, variation, others, old_main_position:)
|
|
320
|
+
old_tail = line[(pos + 1)..] || []
|
|
321
|
+
variation0 = variation[0]
|
|
322
|
+
splice_line!(line, pos, variation)
|
|
323
|
+
cont.variations = []
|
|
324
|
+
old_var = [cont, *old_tail]
|
|
325
|
+
variation0.variations =
|
|
326
|
+
old_main_position == :first ? [old_var, *others] : [*others, old_var]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Replace the mainline continuation +cont+ with its first variation
|
|
330
|
+
# (+vars+ is +cont.variations+, flat). If there are no variations, the
|
|
331
|
+
# line is truncated at the continuation; otherwise the first variation
|
|
332
|
+
# takes the continuation's place and the remaining variations become
|
|
333
|
+
# its variations.
|
|
334
|
+
def delete_mainline_continuation(cont, vars)
|
|
335
|
+
line = @parent.line
|
|
336
|
+
pos = @parent.index + 1
|
|
337
|
+
if vars.empty?
|
|
338
|
+
line[pos..] = []
|
|
339
|
+
else
|
|
340
|
+
first_variation = vars.shift
|
|
341
|
+
splice_line!(line, pos, first_variation)
|
|
342
|
+
cont.variations = []
|
|
343
|
+
first_variation[0].variations = vars
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|