pgn2 1.5.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +34 -3
  3. data/.github/workflows/native.yml +32 -0
  4. data/.github/workflows/publish.yml +44 -2
  5. data/.github/workflows/release-gems.yml +43 -0
  6. data/.github/workflows/release.yml +52 -5
  7. data/.gitignore +9 -1
  8. data/.rubocop.yml +46 -6
  9. data/CHANGELOG.md +215 -0
  10. data/Gemfile +3 -0
  11. data/NOTICE.md +21 -0
  12. data/README.md +134 -5
  13. data/Rakefile +53 -10
  14. data/TODO.md +44 -62
  15. data/bench/baseline_moves.txt +38 -4
  16. data/bench/baseline_parse.txt +4 -4
  17. data/bench/cross_check.rb +61 -0
  18. data/bench/legal_moves.rb +38 -0
  19. data/bench/perft.rb +32 -0
  20. data/bench/profile_moves.rb +93 -0
  21. data/docs/superpowers/plans/2026-08-13-attack-masks-plan.md +51 -0
  22. data/docs/superpowers/plans/2026-08-13-perf-internals-plan.md +883 -0
  23. data/docs/superpowers/plans/2026-08-13-rust-bitboard-perft-plan.md +2442 -0
  24. data/docs/superpowers/plans/2026-08-14-chessie-migration.md +722 -0
  25. data/docs/superpowers/plans/2026-08-14-rust-integration-plan.md +444 -0
  26. data/docs/superpowers/plans/2026-08-15-game-tree-api-plan.md +1014 -0
  27. data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -0
  28. data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
  29. data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
  30. data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
  31. data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
  32. data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
  33. data/ext/pgn2_native/Cargo.lock +321 -0
  34. data/ext/pgn2_native/Cargo.toml +19 -0
  35. data/ext/pgn2_native/extconf.rb +8 -0
  36. data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
  37. data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
  38. data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
  39. data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
  40. data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
  41. data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
  42. data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
  43. data/lib/pgn/attack.rb +97 -0
  44. data/lib/pgn/bitboard.rb +13 -0
  45. data/lib/pgn/board.rb +64 -0
  46. data/lib/pgn/epd.rb +81 -0
  47. data/lib/pgn/fen.rb +42 -46
  48. data/lib/pgn/game.rb +104 -16
  49. data/lib/pgn/move.rb +20 -16
  50. data/lib/pgn/move_calculator.rb +13 -1
  51. data/lib/pgn/node.rb +372 -0
  52. data/lib/pgn/notation.rb +26 -89
  53. data/lib/pgn/pgn_parser.rb +30 -31
  54. data/lib/pgn/pgn_parser.y +14 -15
  55. data/lib/pgn/position.rb +251 -19
  56. data/lib/pgn/serializer.rb +13 -18
  57. data/lib/pgn/version.rb +1 -1
  58. data/lib/pgn/zobrist.rb +53 -0
  59. data/lib/pgn.rb +5 -0
  60. data/pgn2.gemspec +17 -10
  61. data/spec/bitboard_spec.rb +54 -0
  62. data/spec/board_spec.rb +53 -0
  63. data/spec/castling_normalization_spec.rb +39 -0
  64. data/spec/comment_round_trip_spec.rb +35 -0
  65. data/spec/epd_spec.rb +44 -0
  66. data/spec/fen_spec.rb +71 -65
  67. data/spec/game_history_spec.rb +46 -0
  68. data/spec/game_spec.rb +112 -15
  69. data/spec/lexer_spec.rb +5 -5
  70. data/spec/movetext_clean_spec.rb +44 -0
  71. data/spec/node_spec.rb +240 -0
  72. data/spec/notation_spec.rb +5 -0
  73. data/spec/outcome_spec.rb +93 -0
  74. data/spec/parser_left_recursion_spec.rb +37 -0
  75. data/spec/parser_spec.rb +8 -1
  76. data/spec/position_attack_spec.rb +56 -0
  77. data/spec/position_legal_spec.rb +123 -0
  78. data/spec/position_spec.rb +128 -27
  79. data/spec/serializer_spec.rb +4 -4
  80. data/spec/zobrist_spec.rb +46 -0
  81. metadata +113 -35
data/lib/pgn/attack.rb ADDED
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGN
4
+ # {PGN::Attack} is the single source of truth for square-attack queries on
5
+ # a {PGN::Board}. It factors the private attack logic that {PGN::Notation}
6
+ # previously duplicated, so {PGN::Position} (and other consumers) can ask
7
+ # "is this square attacked?" and "which squares attack this square?".
8
+ #
9
+ # Squares are 0x88 indices (see {PGN::Board}); on-board when
10
+ # `(idx & 0x88).zero?`. `color` is 'w' or 'b'.
11
+ module Attack
12
+ BISHOP_DIRS = [-15, 15, -17, 17].freeze
13
+ ROOK_DIRS = [-1, 1, -16, 16].freeze
14
+
15
+ # The 0x88 index of the `color` king on +board+, or nil if absent.
16
+ def self.king_idx(board, color)
17
+ king = color == 'w' ? 'K' : 'k'
18
+ (0...128).each do |idx|
19
+ next if idx.anybits?(0x88)
20
+
21
+ return idx if board.at_index(idx) == king
22
+ end
23
+ nil
24
+ end
25
+
26
+ # Whether +target+ (a 0x88 index) is attacked by any `color` piece.
27
+ def self.attacked?(board, target, color)
28
+ attackers(board, target, color).any?
29
+ end
30
+
31
+ # The algebraic squares of every `color` piece on +board+ that attacks
32
+ # +target+ (a 0x88 index), in no particular order.
33
+ def self.attackers(board, target, color)
34
+ pawn_attackers(board, target, color) +
35
+ knight_attackers(board, target, color) +
36
+ king_attackers(board, target, color) +
37
+ slider_attackers(board, target, color)
38
+ end
39
+
40
+ class << self
41
+ private
42
+
43
+ def pawn_attackers(board, target, color)
44
+ offs = color == 'w' ? [-15, -17] : [15, 17]
45
+ pawn = color == 'w' ? 'P' : 'p'
46
+ offs.each_with_object([]) do |off, a|
47
+ i = target + off
48
+ a << board.square_name(i) if i.nobits?(0x88) && board.at_index(i) == pawn
49
+ end
50
+ end
51
+
52
+ def knight_attackers(board, target, color)
53
+ knight = color == 'w' ? 'N' : 'n'
54
+ Board::KNIGHT_ATTACKS[target].each_with_object([]) do |i, a|
55
+ a << board.square_name(i) if board.at_index(i) == knight
56
+ end
57
+ end
58
+
59
+ def king_attackers(board, target, color)
60
+ king = color == 'w' ? 'K' : 'k'
61
+ Board::KING_ATTACKS[target].each_with_object([]) do |i, a|
62
+ a << board.square_name(i) if board.at_index(i) == king
63
+ end
64
+ end
65
+
66
+ def slider_attackers(board, target, color)
67
+ bishop = color == 'w' ? 'B' : 'b'
68
+ rook = color == 'w' ? 'R' : 'r'
69
+ queen = color == 'w' ? 'Q' : 'q'
70
+ squares = []
71
+ ray_attackers(board, target, BISHOP_DIRS) do |piece, sq|
72
+ squares << sq if piece == bishop || piece == queen
73
+ end
74
+ ray_attackers(board, target, ROOK_DIRS) do |piece, sq|
75
+ squares << sq if piece == rook || piece == queen
76
+ end
77
+ squares
78
+ end
79
+
80
+ # Walk each ray from +target+; yield the first piece hit on that ray
81
+ # along with its square.
82
+ def ray_attackers(board, target, dirs)
83
+ dirs.each do |off|
84
+ i = target + off
85
+ while i.nobits?(0x88)
86
+ piece = board.at_index(i)
87
+ if piece
88
+ yield(piece, board.square_name(i))
89
+ break
90
+ end
91
+ i += off
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load the chessie-backed native bitboard engine if the compiled
4
+ # extension is available. End users get the precompiled native gem (no
5
+ # Rust toolchain needed); if the extension is absent (e.g. running from a
6
+ # source checkout without `rake compile`), the load fails silently so
7
+ # the rest of the gem still works. Code that calls PGN::Bitboard::Engine
8
+ # will raise NameError naturally.
9
+ begin
10
+ require 'pgn2_native/pgn2_native'
11
+ rescue StandardError
12
+ LoadError
13
+ end
data/lib/pgn/board.rb CHANGED
@@ -32,6 +32,31 @@ module PGN
32
32
  RANK_TO_INDEX = ('1'..'8').each_with_index.to_h
33
33
  INDEX_TO_RANK = RANK_TO_INDEX.invert
34
34
 
35
+ # 0x88 knight offsets (a1 + 33 = b3, etc.).
36
+ KNIGHT_OFFS = [33, 31, -31, -33, 18, 14, -14, -18].freeze
37
+ KING_OFFS = [-1, 1, -16, 16, -15, 15, -17, 17].freeze
38
+
39
+ # Precomputed on-board attack masks: entry `idx` is the frozen Array of
40
+ # on-board 0x88 target indices reachable from `idx` by that piece. Built
41
+ # once at load time so the per-call offset + off-board test is replaced by
42
+ # a direct array iteration.
43
+ KNIGHT_ATTACKS = Array.new(128) do |idx|
44
+ next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate
45
+
46
+ KNIGHT_OFFS.each_with_object([]) do |off, a|
47
+ t = idx + off
48
+ a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate
49
+ end.freeze
50
+ end.freeze
51
+ KING_ATTACKS = Array.new(128) do |idx|
52
+ next nil if (idx & 0x88) != 0 # rubocop:disable Style/BitwisePredicate
53
+
54
+ KING_OFFS.each_with_object([]) do |off, a|
55
+ t = idx + off
56
+ a << t if (t & 0x88).zero? # rubocop:disable Style/BitwisePredicate
57
+ end.freeze
58
+ end.freeze
59
+
35
60
  # algebraic to unicode piece lookup
36
61
  #
37
62
  UNICODE_PIECES = {
@@ -264,6 +289,45 @@ module PGN
264
289
  INDEX_TO_FILE[idx & 0x0F] + INDEX_TO_RANK[idx >> 4]
265
290
  end
266
291
 
292
+ # Serializes the board to the FEN board-string portion (ranks 8→1,
293
+ # files a→h, runs of empty squares collapsed to a digit) by walking
294
+ # the 0x88 `@cells` array directly. This avoids rebuilding the 8x8
295
+ # `squares` array on every FEN generation.
296
+ #
297
+ # @return [String] e.g. "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
298
+ def fen_board_string
299
+ rows = []
300
+ 7.downto(0) do |rank|
301
+ s = String.new
302
+ run = 0
303
+ 0.upto(7) do |file|
304
+ piece = @cells[(rank * 16) + file]
305
+ if piece.nil?
306
+ run += 1
307
+ else
308
+ s << run.to_s if run.positive?
309
+ run = 0
310
+ s << piece
311
+ end
312
+ end
313
+ s << run.to_s if run.positive?
314
+ rows << s
315
+ end
316
+ rows.join('/')
317
+ end
318
+
319
+ # Boards are equal when every cell holds the same piece (or is
320
+ # empty), including off-board padding, which is always nil on both.
321
+ def eql?(other)
322
+ other.is_a?(Board) && cells == other.cells
323
+ end
324
+
325
+ alias == eql?
326
+
327
+ protected
328
+
329
+ attr_reader :cells
330
+
267
331
  private
268
332
 
269
333
  def file_of(square)
data/lib/pgn/epd.rb ADDED
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGN
4
+ # {PGN::EPD} translates between strings in Extended Position Description
5
+ # and a {PGN::Position}. EPD is the FEN-like position format used by
6
+ # {http://www.chessprogramming.org/Extended_Position_Description EPD tools}:
7
+ # it shares FEN's first four fields (piece placement, side to move,
8
+ # castling availability, en passant target square) and then carries a
9
+ # trailing list of operations (`ops`) instead of the halfmove/fullmove
10
+ # counters.
11
+ #
12
+ # @example
13
+ # PGN::EPD.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -')
14
+ # PGN::FEN.start.to_epd #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -"
15
+ #
16
+ class EPD
17
+ attr_accessor :board, :active, :ops
18
+ attr_reader :castling, :en_passant
19
+
20
+ # @param epd_string [String] an EPD string: four FEN fields followed by
21
+ # zero or more operation fields (kept verbatim as {#ops}).
22
+ #
23
+ def initialize(epd_string = nil)
24
+ return unless epd_string
25
+
26
+ fields = epd_string.split(' ', 5)
27
+ self.board_string = fields[0]
28
+ self.active = fields[1]
29
+ self.castling = fields[2]
30
+ self.en_passant = fields[3]
31
+ self.ops = fields[4]
32
+ end
33
+
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
+ # @return [PGN::Position] a {PGN::Position} for this EPD. Halfmove and
61
+ # fullmove default to 0 and 1 (EPD does not carry them).
62
+ #
63
+ def to_position
64
+ player = active == 'w' ? :white : :black
65
+ castling_rights = castling.chars - ['-']
66
+ ep = en_passant == '-' ? nil : en_passant
67
+
68
+ PGN::Position.new(board, player, castling_rights, ep, 0, 1)
69
+ end
70
+
71
+ # @return [String] the EPD string (four fields, then +ops+ if present)
72
+ #
73
+ def to_s
74
+ [board_string, active, castling, en_passant, ops].compact.reject(&:empty?).join(' ')
75
+ end
76
+
77
+ def inspect
78
+ to_s
79
+ end
80
+ end
81
+ end
data/lib/pgn/fen.rb CHANGED
@@ -37,9 +37,13 @@ module PGN
37
37
  class FEN
38
38
  # The FEN string representing the starting position in chess
39
39
  #
40
- INITIAL = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
40
+ INITIAL = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'.freeze
41
41
 
42
- attr_accessor :board, :active, :castling, :en_passant, :halfmove, :fullmove
42
+ 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).
46
+ attr_reader :castling, :en_passant
43
47
 
44
48
  # @return [PGN::FEN] a {PGN::FEN} object representing the starting
45
49
  # position
@@ -61,22 +65,22 @@ module PGN
61
65
  # @param fen_string [String] a string in Forsyth-Edwards Notation
62
66
  #
63
67
  def initialize(fen_string = nil)
64
- if fen_string
65
- self.board_string,
66
- self.active,
67
- self.castling,
68
- self.en_passant,
69
- self.halfmove,
70
- self.fullmove = fen_string.split
71
- end
68
+ return unless fen_string
69
+
70
+ self.board_string,
71
+ self.active,
72
+ self.castling,
73
+ self.en_passant,
74
+ self.halfmove,
75
+ self.fullmove = fen_string.split
72
76
  end
73
77
 
74
78
  def en_passant=(val)
75
- @en_passant = val.nil? ? "-" : val
79
+ @en_passant = val.nil? ? '-' : val
76
80
  end
77
81
 
78
82
  def castling=(val)
79
- @castling = (val.nil? || val.empty?) ? "-" : val
83
+ @castling = val.nil? || val.empty? ? '-' : val
80
84
  end
81
85
 
82
86
  # @param board_fen [String] the fen representation of the board
@@ -84,10 +88,10 @@ module PGN
84
88
  # fen.board_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
85
89
  #
86
90
  def board_string=(board_fen)
87
- squares = board_fen.gsub(/\d/) {|match| "_" * match.to_i }
88
- .split("/")
89
- .map {|row| row.split('') }
90
- .map {|row| row.map {|e| e == "_" ? nil : e } }
91
+ squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
92
+ .split('/')
93
+ .map(&:chars)
94
+ .map { |row| row.map { |e| e == '_' ? nil : e } }
91
95
  .reverse
92
96
  .transpose
93
97
  self.board = PGN::Board.new(squares)
@@ -98,59 +102,51 @@ module PGN
98
102
  # PGN::FEN.start.board_string #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
99
103
  #
100
104
  def board_string
101
- rows = self.board.squares.transpose.reverse
102
- rows.map do |row|
103
- s = +""
104
- run = 0
105
- row.each do |e|
106
- if e.nil?
107
- run += 1
108
- else
109
- s << run.to_s if run > 0
110
- run = 0
111
- s << e
112
- end
113
- end
114
- s << run.to_s if run > 0
115
- s
116
- end.join("/")
105
+ board.fen_board_string
117
106
  end
118
107
 
119
108
  # @return [PGN::Position] a {PGN::Position} representing the current
120
109
  # position
121
110
  #
122
111
  def to_position
123
- player = self.active == 'w' ? :white : :black
124
- castling = self.castling.split('') - ['-']
125
- en_passant = self.en_passant == '-' ? nil : en_passant
112
+ player = active == 'w' ? :white : :black
113
+ castling = self.castling.chars - ['-']
114
+ en_passant = self.en_passant == '-' ? nil : self.en_passant
126
115
 
127
116
  PGN::Position.new(
128
- self.board,
117
+ board,
129
118
  player,
130
119
  castling,
131
120
  en_passant,
132
- self.halfmove.to_i,
133
- self.fullmove.to_i,
121
+ halfmove.to_i,
122
+ fullmove.to_i
134
123
  )
135
124
  end
136
125
 
126
+ # @return [String] the EPD string for this position (the first four FEN
127
+ # fields, dropping the halfmove/fullmove counters)
128
+ #
129
+ def to_epd
130
+ PGN::EPD.new("#{board_string} #{active} #{castling} #{en_passant}").to_s
131
+ end
132
+
137
133
  # @return [String] the FEN string
138
134
  # @example
139
135
  # PGN::FEN.start.to_s #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
140
136
  #
141
137
  def to_s
142
138
  [
143
- self.board_string,
144
- self.active,
145
- self.castling,
146
- self.en_passant,
147
- self.halfmove,
148
- self.fullmove,
149
- ].join(" ")
139
+ board_string,
140
+ active,
141
+ castling,
142
+ en_passant,
143
+ halfmove,
144
+ fullmove
145
+ ].join(' ')
150
146
  end
151
147
 
152
148
  def inspect
153
- self.to_s
149
+ to_s
154
150
  end
155
151
  end
156
152
  end
data/lib/pgn/game.rb CHANGED
@@ -30,7 +30,14 @@ module PGN
30
30
  end
31
31
 
32
32
  def clean_text(text)
33
- text&.gsub(/{(.*)}/, '\1')&.gsub(/\s+/, ' ')&.strip
33
+ return unless text
34
+
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
34
41
  end
35
42
  end
36
43
 
@@ -106,16 +113,25 @@ module PGN
106
113
  # @return [Array<PGN::Position>] list of the {PGN::Position}s in the game
107
114
  #
108
115
  def positions
109
- @positions ||= begin
110
- position = starting_position
111
- arr = [position]
112
- moves.each do |move|
113
- new_pos = position.move(move.notation)
114
- arr << new_pos
115
- position = new_pos
116
- end
117
- arr
116
+ @positions ||= each_position.to_a
117
+ end
118
+
119
+ # @return [Enumerator, self] with a block: yields each {PGN::Position}
120
+ # in order (starting position, then one per move) and returns self.
121
+ # Without a block: returns an Enumerator that yields the same.
122
+ #
123
+ # The replay loop is shared with {#positions} so eager and lazy paths
124
+ # produce identical position objects in identical order.
125
+ def each_position
126
+ return enum_for(:each_position) unless block_given?
127
+
128
+ position = starting_position
129
+ yield position
130
+ moves.each do |move|
131
+ position = position.move(move.notation)
132
+ yield position
118
133
  end
134
+ self
119
135
  end
120
136
 
121
137
  # @return [Array<String>] list of the fen representations of the positions
@@ -124,6 +140,70 @@ module PGN
124
140
  positions.map { |p| p.to_fen.inspect }
125
141
  end
126
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
+
127
207
  # Interactively step through the game
128
208
  #
129
209
  # Use +d+ to move forward, +a+ to move backward, and +^C+ to exit.
@@ -148,19 +228,27 @@ module PGN
148
228
  end
149
229
  end
150
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
+
151
241
  private
152
242
 
153
243
  # A MoveText is reused as-is (no new object) when its notation needs no
154
- # '0'->'O' fix and its comment has no braces left to clean; a braced
155
- # comment still needs MoveText.new's second clean_text pass to match the
156
- # 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.
157
247
  def standardize_castling(entry)
158
248
  return MoveText.new(entry.include?('0') ? entry.gsub('0', 'O') : entry) if entry.is_a?(String)
159
249
 
160
250
  notation = entry.notation.include?('0') ? entry.notation.gsub('0', 'O') : entry.notation
161
- if notation.equal?(entry.notation) && (entry.comment.nil? || !entry.comment.include?('{'))
162
- return entry
163
- end
251
+ return entry if notation.equal?(entry.notation)
164
252
 
165
253
  MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
166
254
  end
data/lib/pgn/move.rb CHANGED
@@ -57,8 +57,12 @@ module PGN
57
57
  #
58
58
 
59
59
  class Move
60
- attr_accessor :san, :player
61
- attr_accessor :piece, :destination, :promotion, :check, :capture, :disambiguation, :castle
60
+ attr_accessor :san, :player, :destination, :check
61
+ # piece/promotion/capture/disambiguation/castle have custom writers below
62
+ # (color normalization, nil-coalescing, etc.), so expose only readers here
63
+ # to avoid redefining attr_accessor setters (Lint/DuplicateMethods).
64
+ attr_reader :piece, :promotion, :capture, :disambiguation, :castle
65
+
62
66
  # A regular expression for matching moves in standard algebraic
63
67
  # notation
64
68
  #
@@ -70,7 +74,7 @@ module PGN
70
74
  (?<capture> x ){0}
71
75
  (?<disambiguation> [a-h]?[1-8]? ){0}
72
76
 
73
- (?<castle> O-O(-O)? ){0}
77
+ (?<castle> [O0]-[O0](?:-[O0])? ){0}
74
78
 
75
79
  (?<normal>
76
80
  \g<piece>?
@@ -80,8 +84,8 @@ module PGN
80
84
  \g<promotion>?
81
85
  ){0}
82
86
 
83
- \A (\g<castle> | \g<normal>) \g<check>? \z
84
- /x.freeze
87
+ \A (?:\g<castle> | \g<normal>) \g<check>? \z
88
+ /x
85
89
 
86
90
  # @param move [String] the move in SAN
87
91
  # @param player [Symbol] the player making the move
@@ -108,17 +112,17 @@ module PGN
108
112
  # Castling SAN (O-O / O-O-O) has no piece attribute; the castle attribute
109
113
  # is set later in #initialize. Use a non-allocating prefix check instead
110
114
  # of `san.match('O-O')`, which allocated a MatchData on every Move.new.
111
- return if san.start_with?('O')
115
+ return if san.start_with?('O') || san.start_with?('0')
112
116
 
113
117
  val ||= 'P'
114
118
  @piece = black? ? val.downcase : val
115
119
  end
116
120
 
117
121
  def promotion=(val)
118
- if val
119
- val.downcase! if black?
120
- @promotion = val.delete('=')
121
- end
122
+ return unless val
123
+
124
+ val.downcase! if black?
125
+ @promotion = val.delete('=')
122
126
  end
123
127
 
124
128
  def capture=(val)
@@ -130,11 +134,11 @@ module PGN
130
134
  end
131
135
 
132
136
  def castle=(val)
133
- if val
134
- @castle = 'K' if val == 'O-O'
135
- @castle = 'Q' if val == 'O-O-O'
136
- @castle.downcase! if black?
137
- end
137
+ return unless val
138
+
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
+ @castle.downcase! if black?
138
142
  end
139
143
 
140
144
  # @return [Boolean] whether the move results in check
@@ -164,7 +168,7 @@ module PGN
164
168
  # @return [Boolean] whether the piece being moved is a pawn
165
169
  #
166
170
  def pawn?
167
- piece == 'P' || piece == 'p'
171
+ %w[P p].include?(piece)
168
172
  end
169
173
  end
170
174
  end
@@ -191,7 +191,7 @@ module PGN
191
191
 
192
192
  possibilities = case move.piece
193
193
  when 'B', 'R', 'Q', 'b', 'r', 'q' then direction_origins
194
- when 'K', 'N', 'k', 'n' then move_origins
194
+ when 'K', 'N', 'k', 'n' then leaper_origins
195
195
  when 'P', 'p' then pawn_origins
196
196
  else # don't care move, used in variations
197
197
  return nil
@@ -237,6 +237,18 @@ module PGN
237
237
  possibilities
238
238
  end
239
239
 
240
+ # Knight/king origins use the precomputed on-board attack masks on
241
+ # {PGN::Board}, skipping the per-call off-board test.
242
+ def leaper_origins
243
+ dest = dest_idx
244
+ mask = move.piece.upcase == 'N' ? Board::KNIGHT_ATTACKS[dest] : Board::KING_ATTACKS[dest]
245
+ piece = move.piece
246
+
247
+ possibilities = []
248
+ mask.each { |t| possibilities << t if board.at_index(t) == piece }
249
+ possibilities
250
+ end
251
+
240
252
  # Computes the possible pawn origins based on the destination square
241
253
  # and whether or not the move is a capture.
242
254
  #