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.
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
- text.gsub(/{(.*)}/, '\1').gsub(/\s+/, ' ').strip
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 and its comment has no braces left to clean; a braced
166
- # comment still needs MoveText.new's second clean_text pass to match the
167
- # legacy whittle byte-output.
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) && (entry.comment.nil? || !entry.comment.include?('{'))
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> O-O(?:-O)? ){0}
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 val == 'O-O'
140
- @castle = 'Q' if val == 'O-O-O'
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
- def king_idx(board, color)
282
- king = color == 'w' ? 'K' : 'k'
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.