pgn2 2.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6363546a03960706a76a2048b8e88aff387e1f475eb76add1e1cfd615c6b6ed
4
- data.tar.gz: 3944288b73d63291f31c0218902206eb39e2d8173fca7a91bb80aea59e0981a5
3
+ metadata.gz: 8fdc6dcb9543c25e2e1102902d031f9d733463780b6f5d16a1a4713c6cfe16b9
4
+ data.tar.gz: 051ba77cdb0e657909ef5a22c1470f229c883e2bcb70cbfd3b9dbfbb83e92b8e
5
5
  SHA512:
6
- metadata.gz: 1bb3c90fcc6afcf5a2a7ca8a285518035de7645a7627c8d64ed89200e16e364f4f687dfe2107a3b3d6f2b2d994ce8cac5620f2f2d9418bdcd874cc84d07dddc5
7
- data.tar.gz: b8b0e3a0254a29397d2ac263e16ea51ed524d886e2a9c69107d9cfa3c3ae4b6ba5cbbbfd8d4a14d73d74c5525f03102cd4aacbcee93ce5a9263288b05fd4e089
6
+ metadata.gz: 3999930da7369748415bfb222108d436a64c67bf8e91c5b0d12d9dc5ab38f64c78f1e1e56abca9454ed8e1a215df0918d152e37b1271808d8e1962088029a0e7
7
+ data.tar.gz: 2dec3d79ee03350d252bd846a789b3b96091f6ec34cd788d60e267506c2837436659395472fef192f3aeec8290ef32e3f7bd235308062a1de12f1add0e110ab7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.2 (2026-08-18)
4
+
5
+ ### Summary
6
+
7
+ Internal refactor and performance pass — no public API changes. The
8
+ `pgn2-bitboard` adapter drops its hand-rolled `Move`/`MoveList`/`uci_parse`
9
+ layer in favor of `chessie`'s own `MoveList` and `Move: PartialEq<str>`
10
+ (UCI comparison), and the Ruby side DRYs shared FEN/EPD parsing, memoizes
11
+ `Position#in_check?`, and maintains `Game`'s position cache incrementally.
12
+
13
+ ### Changed
14
+
15
+ - **`ext/pgn2_native`** — remove `pgn2-bitboard/src/moves.rs`;
16
+ `Board#legal_moves` returns `chessie::MoveList` directly and
17
+ `Engine#legal?` compares via `chessie::Move`'s `PartialEq<str>` (by
18
+ `to_uci()`), eliminating the separate UCI parser.
19
+ - **`PGN::PositionFields`** — shared FEN/EPD module for board-string and
20
+ castling/en-passant field parsing; `FEN` and `EPD` now include it instead
21
+ of duplicating the logic.
22
+ - **`PGN::Attack.attacked?`** — short-circuits pawn/knight/king checks before
23
+ the slider ray-walk (cheapest first), matching the previous result.
24
+ - **`PGN::Position#in_check?`** — memoizes its result on first call.
25
+ - **`PGN::Game`** — `push`/`pop` now grow/shrink the memoized `@positions`
26
+ list in step rather than invalidating it; `threefold?` reuses `#positions`.
27
+ - **`PGN::MoveText.normalize_castling`** — castling `0`→`O` normalization
28
+ extracted as a class method (was a private `Node` helper).
29
+ - **`PGN::Node`** — `promote`/`demote`/`promote_to_main`/`demote_to_last`/
30
+ `delete` share a `mutate_sibling` prologue/epilogue and a `splice_line!`
31
+ helper; behavior unchanged.
32
+
33
+ ---
34
+
3
35
  ## 2.0.1 (2026-08-15)
4
36
 
5
37
  ### Summary
@@ -1,5 +1,3 @@
1
- use crate::moves::{Move, MoveList};
2
-
3
1
  /// A chess position backed by `chessie::Game`. Holds no chess logic of
4
2
  /// its own; every operation delegates. `Copy` because `chessie::Game`
5
3
  /// is `Copy`; `Default` because the magnus `Engine` wraps it in a
@@ -20,13 +18,7 @@ impl Board {
20
18
  self.game.perft(depth as usize)
21
19
  }
22
20
 
23
- pub fn legal_moves(&self) -> MoveList {
24
- let moves: Vec<Move> = self
25
- .game
26
- .get_legal_moves()
27
- .into_iter()
28
- .map(Move::from_chessie)
29
- .collect();
30
- MoveList(moves)
21
+ pub fn legal_moves(&self) -> chessie::MoveList {
22
+ self.game.get_legal_moves()
31
23
  }
32
24
  }
@@ -5,8 +5,6 @@
5
5
  //! Ruby in the loop.
6
6
 
7
7
  pub mod board;
8
- pub mod moves;
9
8
  pub mod perft;
10
9
 
11
10
  pub use board::Board;
12
- pub use moves::{Move, MoveList};
@@ -32,10 +32,10 @@ impl Engine {
32
32
  }
33
33
 
34
34
  fn legal_p(&self, uci: String) -> bool {
35
- match pgn2_bitboard::moves::uci_parse(&uci) {
36
- Some(parsed) => self.0.borrow().legal_moves().iter().any(|m| m.same_target(parsed)),
37
- None => false,
38
- }
35
+ // `chessie::Move`'s `PartialEq<str>` compares by `to_uci()`, so this
36
+ // matches on castle/promotion notation the same way `legal_moves_ruby`
37
+ // renders it — no separate UCI parser needed.
38
+ self.0.borrow().legal_moves().iter().any(|m| m == &uci)
39
39
  }
40
40
  }
41
41
 
@@ -49,6 +49,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
49
49
  engine.define_method("initialize", method!(Engine::initialize, 1))?;
50
50
  engine.define_method("perft", method!(Engine::perft, 1))?;
51
51
  engine.define_method("legal_moves", method!(Engine::legal_moves_ruby, 0))?;
52
- engine.define_method("legal?", method!(Engine::legal_p, 1))?;;
52
+ engine.define_method("legal?", method!(Engine::legal_p, 1))?;
53
53
  Ok(())
54
54
  }
data/lib/pgn/attack.rb CHANGED
@@ -14,7 +14,7 @@ module PGN
14
14
 
15
15
  # The 0x88 index of the `color` king on +board+, or nil if absent.
16
16
  def self.king_idx(board, color)
17
- king = color == 'w' ? 'K' : 'k'
17
+ king = piece_letter('K', color)
18
18
  (0...128).each do |idx|
19
19
  next if idx.anybits?(0x88)
20
20
 
@@ -24,8 +24,13 @@ module PGN
24
24
  end
25
25
 
26
26
  # Whether +target+ (a 0x88 index) is attacked by any `color` piece.
27
+ # Checked (and short-circuited) piece type by piece type, cheapest first,
28
+ # so a hit skips the pricier ray-walk sliders scan entirely.
27
29
  def self.attacked?(board, target, color)
28
- attackers(board, target, color).any?
30
+ pawn_attackers(board, target, color).any? ||
31
+ knight_attackers(board, target, color).any? ||
32
+ king_attackers(board, target, color).any? ||
33
+ slider_attackers(board, target, color).any?
29
34
  end
30
35
 
31
36
  # The algebraic squares of every `color` piece on +board+ that attacks
@@ -40,9 +45,14 @@ module PGN
40
45
  class << self
41
46
  private
42
47
 
48
+ # 'w' -> the uppercase letter, 'b' -> the lowercase letter.
49
+ def piece_letter(letter, color)
50
+ color == 'w' ? letter : letter.downcase
51
+ end
52
+
43
53
  def pawn_attackers(board, target, color)
44
54
  offs = color == 'w' ? [-15, -17] : [15, 17]
45
- pawn = color == 'w' ? 'P' : 'p'
55
+ pawn = piece_letter('P', color)
46
56
  offs.each_with_object([]) do |off, a|
47
57
  i = target + off
48
58
  a << board.square_name(i) if i.nobits?(0x88) && board.at_index(i) == pawn
@@ -50,23 +60,23 @@ module PGN
50
60
  end
51
61
 
52
62
  def knight_attackers(board, target, color)
53
- knight = color == 'w' ? 'N' : 'n'
63
+ knight = piece_letter('N', color)
54
64
  Board::KNIGHT_ATTACKS[target].each_with_object([]) do |i, a|
55
65
  a << board.square_name(i) if board.at_index(i) == knight
56
66
  end
57
67
  end
58
68
 
59
69
  def king_attackers(board, target, color)
60
- king = color == 'w' ? 'K' : 'k'
70
+ king = piece_letter('K', color)
61
71
  Board::KING_ATTACKS[target].each_with_object([]) do |i, a|
62
72
  a << board.square_name(i) if board.at_index(i) == king
63
73
  end
64
74
  end
65
75
 
66
76
  def slider_attackers(board, target, color)
67
- bishop = color == 'w' ? 'B' : 'b'
68
- rook = color == 'w' ? 'R' : 'r'
69
- queen = color == 'w' ? 'Q' : 'q'
77
+ bishop = piece_letter('B', color)
78
+ rook = piece_letter('R', color)
79
+ queen = piece_letter('Q', color)
70
80
  squares = []
71
81
  ray_attackers(board, target, BISHOP_DIRS) do |piece, sq|
72
82
  squares << sq if piece == bishop || piece == queen
data/lib/pgn/epd.rb CHANGED
@@ -14,6 +14,8 @@ module PGN
14
14
  # PGN::FEN.start.to_epd #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -"
15
15
  #
16
16
  class EPD
17
+ include PositionFields
18
+
17
19
  attr_accessor :board, :active, :ops
18
20
  attr_reader :castling, :en_passant
19
21
 
@@ -31,40 +33,11 @@ module PGN
31
33
  self.ops = fields[4]
32
34
  end
33
35
 
34
- def castling=(val)
35
- @castling = val.nil? || val.empty? ? '-' : val
36
- end
37
-
38
- def en_passant=(val)
39
- @en_passant = val.nil? ? '-' : val
40
- end
41
-
42
- # @param board_fen [String] the FEN/EPD representation of the board
43
- #
44
- def board_string=(board_fen)
45
- squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
46
- .split('/')
47
- .map(&:chars)
48
- .map { |row| row.map { |e| e == '_' ? nil : e } }
49
- .reverse
50
- .transpose
51
- self.board = PGN::Board.new(squares)
52
- end
53
-
54
- # @return [String] the EPD board-string portion
55
- #
56
- def board_string
57
- board.fen_board_string
58
- end
59
-
60
36
  # @return [PGN::Position] a {PGN::Position} for this EPD. Halfmove and
61
37
  # fullmove default to 0 and 1 (EPD does not carry them).
62
38
  #
63
39
  def to_position
64
- player = active == 'w' ? :white : :black
65
- castling_rights = castling.chars - ['-']
66
- ep = en_passant == '-' ? nil : en_passant
67
-
40
+ player, castling_rights, ep = position_fields
68
41
  PGN::Position.new(board, player, castling_rights, ep, 0, 1)
69
42
  end
70
43
 
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 below (normalize nil/empty
44
- # to '-'), so expose readers here and define the writers explicitly to avoid
45
- # redefining the accessors generated by attr_accessor (Lint/DuplicateMethods).
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,12 @@ 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 = active == 'w' ? :white : :black
113
- castling = self.castling.chars - ['-']
114
- en_passant = self.en_passant == '-' ? nil : self.en_passant
115
-
116
- PGN::Position.new(
117
- board,
118
- player,
119
- castling,
120
- en_passant,
121
- halfmove.to_i,
122
- fullmove.to_i
123
- )
125
+ player, castling_rights, ep = position_fields
126
+ PGN::Position.new(board, player, castling_rights, ep, halfmove.to_i, fullmove.to_i)
124
127
  end
125
128
 
126
129
  # @return [String] the EPD string for this position (the first four FEN
data/lib/pgn/game.rb CHANGED
@@ -29,6 +29,11 @@ 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
 
@@ -150,7 +155,7 @@ module PGN
150
155
 
151
156
  # Append a move in SAN, validating legality when the native engine is
152
157
  # available. Raises ArgumentError for an illegal move (when the engine is
153
- # loaded) and invalidates the memoized position list.
158
+ # loaded). Grows the memoized position list in step, if it is populated.
154
159
  #
155
160
  # @param san [String, PGN::MoveText] the move to append
156
161
  # @return [self]
@@ -161,31 +166,31 @@ module PGN
161
166
  end
162
167
 
163
168
  @moves << move
164
- @positions = nil
169
+ @positions << @positions.last.move(move.notation) if @positions
165
170
  self
166
171
  end
167
172
 
168
- # Remove and return the last move, or nil if there are none. Invalidates
169
- # the memoized position list.
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.
170
175
  #
171
176
  # @return [PGN::MoveText, nil]
172
177
  def pop
173
178
  return nil if @moves.empty?
174
179
 
175
180
  move = @moves.pop
176
- @positions = nil
181
+ @positions&.pop
177
182
  move
178
183
  end
179
184
 
180
185
  # Whether any position has occurred three times in this game (the
181
186
  # 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.
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.
184
189
  #
185
190
  # @return [Boolean]
186
191
  def threefold?
187
192
  counts = Hash.new(0)
188
- each_position { |position| counts[position.hash] += 1 }
193
+ positions.each { |position| counts[position.hash] += 1 }
189
194
  counts.any? { |_, count| count >= 3 }
190
195
  end
191
196
 
@@ -245,9 +250,9 @@ module PGN
245
250
  # (it only strips a *single* outermost brace pair), so reusing or rebuilding
246
251
  # a MoveText never corrupts a comment that still contains inner braces.
247
252
  def standardize_castling(entry)
248
- return MoveText.new(entry.include?('0') ? entry.gsub('0', 'O') : entry) if entry.is_a?(String)
253
+ return MoveText.new(MoveText.normalize_castling(entry)) if entry.is_a?(String)
249
254
 
250
- notation = entry.notation.include?('0') ? entry.notation.gsub('0', 'O') : entry.notation
255
+ notation = MoveText.normalize_castling(entry.notation)
251
256
  return entry if notation.equal?(entry.notation)
252
257
 
253
258
  MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
data/lib/pgn/node.rb CHANGED
@@ -141,14 +141,7 @@ module PGN
141
141
  @line.push(*new_line)
142
142
  else
143
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]
144
+ make_mainline(@line, @index + 1, cont, new_line, cont.variations, old_main_position: :first)
152
145
  end
153
146
  @game.root
154
147
  end
@@ -157,16 +150,9 @@ module PGN
157
150
  # No-op for the mainline (index 0) or the first variation (index 1).
158
151
  # Returns a fresh +game.root+.
159
152
  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
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
170
156
  end
171
157
 
172
158
  # Move this node one slot toward the end among its siblings. At index 0
@@ -174,83 +160,54 @@ module PGN
174
160
  # mainline and the old mainline becomes variation #1. No-op at the last
175
161
  # index. Returns a fresh +game.root+.
176
162
  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]
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
191
170
  end
192
- @game.root
193
171
  end
194
172
 
195
173
  # Make this node the mainline at its branching point (the old mainline
196
174
  # becomes variation #1). No-op if already the mainline. Returns a
197
175
  # fresh +game.root+.
198
176
  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
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
210
181
  end
211
182
 
212
183
  # Move this node to the last position among its siblings. At index 0
213
184
  # the last variation becomes the new mainline and the old mainline
214
185
  # becomes the last variation. Returns a fresh +game.root+.
215
186
  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)
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
232
197
  end
233
- @game.root
234
198
  end
235
199
 
236
200
  # Remove this node and its subtree. If it is the mainline continuation,
237
201
  # the first remaining variation (if any) takes its place; otherwise the
238
202
  # line is truncated at this point. Returns a fresh +game.root+.
239
203
  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)
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
252
210
  end
253
- @game.root
254
211
  end
255
212
 
256
213
  private
@@ -295,11 +252,25 @@ module PGN
295
252
  # SANs, applying the same castling 0->O normalization as Game#moves=.
296
253
  def build_movetexts(move_or_moves)
297
254
  sans = move_or_moves.is_a?(String) ? [move_or_moves] : move_or_moves
298
- sans.map { |s| PGN::MoveText.new(normalize_castling(s)) }
255
+ sans.map { |s| PGN::MoveText.new(PGN::MoveText.normalize_castling(s)) }
299
256
  end
300
257
 
301
- def normalize_castling(san)
302
- san.include?('0') ? san.gsub('0', 'O') : san
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
303
274
  end
304
275
 
305
276
  # Hoist every variation first-move branching at the position before
@@ -331,18 +302,24 @@ module PGN
331
302
  end
332
303
  end
333
304
 
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
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:)
342
320
  old_tail = line[(pos + 1)..] || []
343
321
  variation0 = variation[0]
344
- line[pos] = variation0
345
- line[(pos + 1)..] = (variation[1..] || [])
322
+ splice_line!(line, pos, variation)
346
323
  cont.variations = []
347
324
  old_var = [cont, *old_tail]
348
325
  variation0.variations =
@@ -361,11 +338,9 @@ module PGN
361
338
  line[pos..] = []
362
339
  else
363
340
  first_variation = vars.shift
364
- first_variation_move = first_variation[0]
365
- line[pos] = first_variation_move
366
- line[(pos + 1)..] = (first_variation[1..] || [])
341
+ splice_line!(line, pos, first_variation)
367
342
  cont.variations = []
368
- first_variation_move.variations = vars
343
+ first_variation[0].variations = vars
369
344
  end
370
345
  end
371
346
  end
data/lib/pgn/position.rb CHANGED
@@ -179,8 +179,10 @@ module PGN
179
179
  #
180
180
  # @return [Boolean]
181
181
  def in_check?
182
+ return @in_check if defined?(@in_check)
183
+
182
184
  king = PGN::Attack.king_idx(board, mover_color)
183
- !king.nil? && PGN::Attack.attacked?(board, king, opponent_color)
185
+ @in_check = !king.nil? && PGN::Attack.attacked?(board, king, opponent_color)
184
186
  end
185
187
 
186
188
  # The algebraic squares of every piece of the given color that attacks
data/lib/pgn/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module PGN
2
- VERSION = '2.0.1'.freeze
2
+ VERSION = '2.0.2'.freeze
3
3
  end
data/spec/game_spec.rb CHANGED
@@ -133,20 +133,6 @@ describe PGN::Game do
133
133
  describe 'node mutation round-trip' do
134
134
  non_round_trip = %w[doublequotes.pgn specialcharacters.pgn].freeze
135
135
 
136
- # A node-sig tree with variations sorted, so the comparison is
137
- # order-independent (the parser reverses variation order on each parse).
138
- def node_sig(move)
139
- [move.notation, (move.variations || []).map { line_sig(_1) }.sort]
140
- end
141
-
142
- def line_sig(line)
143
- line.map { node_sig(_1) }
144
- end
145
-
146
- def tree_sig(game)
147
- game.moves.map { node_sig(_1) }
148
- end
149
-
150
136
  # First node (DFS over children) that has at least one variation. Such a
151
137
  # node always has a non-nil continuation, so add_variation can branch.
152
138
  def first_branch_with_variations(node)
data/spec/node_spec.rb CHANGED
@@ -1,23 +1,5 @@
1
1
  require 'spec_helper'
2
2
 
3
- # Helpers for order-independent variation comparison. The parser's
4
- # `variation_list` rule is right-recursive, so a move's variations are stored
5
- # in reverse of their PGN-text order; round-tripping flips that order each
6
- # parse. We therefore compare variation trees as sorted signatures (the same
7
- # approach the existing game_spec round-trip gate uses), and reference
8
- # variations by notation rather than by index.
9
- def node_sig(move)
10
- [move.notation, (move.variations || []).map { line_sig(_1) }.sort]
11
- end
12
-
13
- def line_sig(line)
14
- line.map { node_sig(_1) }
15
- end
16
-
17
- def tree_sig(game)
18
- game.moves.map { node_sig(_1) }
19
- end
20
-
21
3
  describe PGN::Node do
22
4
  let(:game) { PGN.parse(File.read(File.expand_path('pgn_files/variations.pgn', __dir__), encoding: Encoding::ISO_8859_1)).first }
23
5
  let(:root) { game.root }
data/spec/spec_helper.rb CHANGED
@@ -1,5 +1,22 @@
1
1
  require 'pgn'
2
2
 
3
+ # Helpers for order-independent variation comparison. The parser's
4
+ # `variation_list` rule is right-recursive, so a move's variations are stored
5
+ # in reverse of their PGN-text order; round-tripping flips that order each
6
+ # parse. We therefore compare variation trees as sorted signatures, and
7
+ # reference variations by notation rather than by index.
8
+ def node_sig(move)
9
+ [move.notation, (move.variations || []).map { line_sig(_1) }.sort]
10
+ end
11
+
12
+ def line_sig(line)
13
+ line.map { node_sig(_1) }
14
+ end
15
+
16
+ def tree_sig(game)
17
+ game.moves.map { node_sig(_1) }
18
+ end
19
+
3
20
  # This file was generated by the `rspec --init` command. Conventionally, all
4
21
  # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
5
22
  # Require this file using `require "spec_helper"` to ensure that it is only
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgn2
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.1
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stacey Touset
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-08-15 00:00:00.000000000 Z
12
+ date: 2026-08-18 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rb_sys
@@ -242,7 +242,6 @@ files:
242
242
  - ext/pgn2_native/pgn2-bitboard/Cargo.toml
243
243
  - ext/pgn2_native/pgn2-bitboard/src/board.rs
244
244
  - ext/pgn2_native/pgn2-bitboard/src/lib.rs
245
- - ext/pgn2_native/pgn2-bitboard/src/moves.rs
246
245
  - ext/pgn2_native/pgn2-bitboard/src/perft.rs
247
246
  - ext/pgn2_native/pgn2_native/Cargo.toml
248
247
  - ext/pgn2_native/pgn2_native/src/lib.rs
@@ -1,121 +0,0 @@
1
- /// Adapter move carrying exactly what `to_uci` and `same_target` need:
2
- /// from-square, to-square, and optional promotion kind. No flag bits,
3
- /// so a position-free `uci_parse` can produce a comparable token — this
4
- /// preserves the existing `same_target` semantics (match on
5
- /// from + to + promo only), so `legal?("e1g1")` finds the castle and
6
- /// `legal?("e7e8q")` finds that exact promotion.
7
- #[derive(Clone, Copy, PartialEq, Eq)]
8
- pub struct Move {
9
- from: u8, // 0..=63, index = rank * 8 + file
10
- to: u8,
11
- promo: Option<Promo>,
12
- }
13
-
14
- #[derive(Clone, Copy, PartialEq, Eq)]
15
- enum Promo {
16
- Knight,
17
- Bishop,
18
- Rook,
19
- Queen,
20
- }
21
-
22
- impl Move {
23
- /// Build an adapter `Move` from a `chessie::Move` (a legal move).
24
- ///
25
- /// chessie encodes castling internally as "King takes Rook"
26
- /// (`to` = the rook's square, Chess960 style). `into_standard_castle`
27
- /// rewrites the `to` square to the king's destination (`e1g1`/`e1c1`),
28
- /// matching the standard-UCI output the previous engine produced and
29
- /// that `uci_parse` / `bitboard_spec.rb` expect. No-op for non-castle
30
- /// moves.
31
- pub(crate) fn from_chessie(m: chessie::Move) -> Self {
32
- let m = m.into_standard_castle();
33
- Move {
34
- from: m.from().index() as u8,
35
- to: m.to().index() as u8,
36
- promo: m.promotion().map(|kind| match kind {
37
- chessie::PieceKind::Knight => Promo::Knight,
38
- chessie::PieceKind::Bishop => Promo::Bishop,
39
- chessie::PieceKind::Rook => Promo::Rook,
40
- chessie::PieceKind::Queen => Promo::Queen,
41
- // Pawns/Kings never appear as a promotion kind.
42
- _ => unreachable!("non-promotion PieceKind in Move::promotion"),
43
- }),
44
- }
45
- }
46
-
47
- /// UCI string: `"e2e4"`, `"e1g1"` (castle, king from→to),
48
- /// `"e7e8q"` (promotion). Equal to `chessie::Move::to_uci` for
49
- /// every legal move (castle and en-passant both reduce to from+to
50
- /// in UCI), so the sorted-UCI output is unchanged.
51
- pub fn to_uci(self) -> String {
52
- let mut s = String::with_capacity(5);
53
- s.push_str(&sq_name(self.from));
54
- s.push_str(&sq_name(self.to));
55
- if let Some(p) = self.promo {
56
- s.push(match p {
57
- Promo::Knight => 'n',
58
- Promo::Bishop => 'b',
59
- Promo::Rook => 'r',
60
- Promo::Queen => 'q',
61
- });
62
- }
63
- s
64
- }
65
-
66
- /// Match on from + to + promo (the pre-existing semantics).
67
- pub fn same_target(self, other: Move) -> bool {
68
- self.from == other.from && self.to == other.to && self.promo == other.promo
69
- }
70
- }
71
-
72
- /// Thin wrapper around `Vec<Move>`; the binding calls `.iter()` on it.
73
- pub struct MoveList(pub Vec<Move>);
74
-
75
- impl MoveList {
76
- pub fn iter(&self) -> std::slice::Iter<'_, Move> {
77
- self.0.iter()
78
- }
79
- }
80
-
81
- /// Parse a UCI string into a `Move` token **without** a position.
82
- /// Only `to_uci`/`same_target` consume the result, so from + to + promo
83
- /// is all that is needed. Returns `None` on any malformed input.
84
- pub fn uci_parse(s: &str) -> Option<Move> {
85
- let b = s.as_bytes();
86
- if b.len() < 4 {
87
- return None;
88
- }
89
- let from = parse_sq(&b[0..2])?;
90
- let to = parse_sq(&b[2..4])?;
91
- let promo = if b.len() >= 5 {
92
- Some(match b[4] {
93
- b'n' => Promo::Knight,
94
- b'b' => Promo::Bishop,
95
- b'r' => Promo::Rook,
96
- b'q' => Promo::Queen,
97
- _ => return None,
98
- })
99
- } else {
100
- None
101
- };
102
- Some(Move { from, to, promo })
103
- }
104
-
105
- fn parse_sq(t: &[u8]) -> Option<u8> {
106
- let f = t[0].checked_sub(b'a')?;
107
- let r = t[1].checked_sub(b'1')?;
108
- if f > 7 || r > 7 {
109
- return None;
110
- }
111
- Some(r * 8 + f)
112
- }
113
-
114
- fn sq_name(idx: u8) -> String {
115
- let file = (b'a' + (idx & 7)) as char;
116
- let rank = (b'1' + (idx >> 3)) as char;
117
- let mut s = String::with_capacity(2);
118
- s.push(file);
119
- s.push(rank);
120
- s
121
- }