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
@@ -8,7 +8,7 @@ require 'racc/parser.rb'
8
8
  module PGN
9
9
  class PgnParser < Racc::Parser
10
10
 
11
- module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 97)
11
+ module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 96)
12
12
 
13
13
  def parse(input)
14
14
  @lexer = PGN::Lexer.new(input)
@@ -71,37 +71,37 @@ racc_action_table = [
71
71
  21, 25, 31 ]
72
72
 
73
73
  racc_action_check = [
74
- 1, 28, 2, 28, 17, 28, 5, 17, 6, 1,
75
- 28, 9, 9, 9, 24, 9, 11, 24, 15, 18,
76
- 20, 23, 29 ]
74
+ 1, 28, 2, 28, 17, 28, 4, 17, 6, 1,
75
+ 28, 10, 10, 10, 24, 10, 11, 24, 15, 18,
76
+ 19, 23, 29 ]
77
77
 
78
78
  racc_action_pointer = [
79
- nil, 0, 2, nil, nil, -3, 0, nil, nil, 8,
80
- nil, 14, nil, nil, nil, 7, nil, 1, 9, nil,
81
- 9, nil, nil, 15, 11, nil, nil, nil, -2, 16,
79
+ nil, 0, 2, nil, -3, nil, 0, nil, nil, nil,
80
+ 8, 14, nil, nil, nil, 7, nil, 1, 9, 9,
81
+ nil, nil, nil, 15, 11, nil, nil, nil, -2, 16,
82
82
  nil, nil, nil ]
83
83
 
84
84
  racc_action_default = [
85
- -1, -25, -25, -2, -8, -4, -25, 33, -3, -25,
86
- -5, -25, -7, -9, -10, -11, -13, -14, -25, -12,
85
+ -1, -25, -25, -2, -8, -4, -25, 33, -3, -5,
86
+ -25, -25, -7, -9, -10, -11, -13, -14, -25, -12,
87
87
  -22, -8, -15, -16, -17, -20, -6, -23, -25, -19,
88
88
  -18, -21, -24 ]
89
89
 
90
90
  racc_goto_table = [
91
- 9, 24, 4, 19, 1, 3, 10, 29, 27, 8,
92
- 22, nil, nil, nil, nil, nil, nil, 28 ]
91
+ 10, 24, 20, 1, 3, 5, 27, 29, 9, 4,
92
+ 8, 19, 22, nil, nil, nil, nil, 28 ]
93
93
 
94
94
  racc_goto_check = [
95
- 6, 11, 3, 9, 1, 2, 3, 11, 9, 4,
96
- 10, nil, nil, nil, nil, nil, nil, 6 ]
95
+ 6, 11, 12, 1, 2, 5, 12, 11, 5, 3,
96
+ 4, 9, 10, nil, nil, nil, nil, 6 ]
97
97
 
98
98
  racc_goto_pointer = [
99
- nil, 4, 4, 1, 5, nil, -4, nil, nil, -12,
100
- -7, -16, nil ]
99
+ nil, 3, 3, 8, 6, 4, -4, nil, nil, -4,
100
+ -5, -16, -13 ]
101
101
 
102
102
  racc_goto_default = [
103
- nil, nil, nil, nil, nil, 5, nil, 13, 15, nil,
104
- nil, nil, 20 ]
103
+ nil, nil, nil, nil, nil, nil, nil, 13, 15, nil,
104
+ nil, nil, nil ]
105
105
 
106
106
  racc_reduce_table = [
107
107
  0, 0, :racc_error,
@@ -223,7 +223,7 @@ module_eval(<<'.,.,', 'pgn_parser.y', 15)
223
223
  def _reduce_3(val, _values, result)
224
224
  result = val[1].pop
225
225
  result = {
226
- tags: val[0],
226
+ tags: val[0].reverse_each.with_object({}) { |pair, h| h.merge!(pair) },
227
227
  result: result,
228
228
  moves: val[1],
229
229
  pgn: nil,
@@ -236,18 +236,18 @@ module_eval(<<'.,.,', 'pgn_parser.y', 15)
236
236
 
237
237
  module_eval(<<'.,.,', 'pgn_parser.y', 26)
238
238
  def _reduce_4(val, _values, result)
239
- result = val[0]
239
+ result = [val[0]]
240
240
  result
241
241
  end
242
242
  .,.,
243
243
 
244
244
  module_eval(<<'.,.,', 'pgn_parser.y', 29)
245
245
  def _reduce_5(val, _values, result)
246
- # Right-recursive with section.merge(pair) reproduces the legacy
247
- # whittle parser's tag semantics exactly: reverse source insertion
248
- # order, and first-occurrence-wins on duplicate keys. This keeps
249
- # serialized tag order byte-compatible with the legacy behavior.
250
- result = val[1].merge(val[0])
246
+ # Left-recursive; the source-order array is reduced to the legacy
247
+ # tag semantics in {pgn_game} (reverse, then merge with
248
+ # first-occurrence-wins) so the whittle-order compatibility quirk is
249
+ # a single explicit line instead of implicit in recursion direction.
250
+ result = val[0] << val[1]
251
251
 
252
252
  result
253
253
  end
@@ -294,7 +294,7 @@ module_eval(<<'.,.,', 'pgn_parser.y', 50)
294
294
  module_eval(<<'.,.,', 'pgn_parser.y', 54)
295
295
  def _reduce_12(val, _values, result)
296
296
  result = val[0]
297
- result.variations = val[1]
297
+ result.variations = val[1].reverse
298
298
  result
299
299
 
300
300
  result
@@ -377,17 +377,16 @@ module_eval(<<'.,.,', 'pgn_parser.y', 82)
377
377
 
378
378
  module_eval(<<'.,.,', 'pgn_parser.y', 85)
379
379
  def _reduce_23(val, _values, result)
380
- # Right-recursive prepend reproduces the legacy whittle parser's
381
- # variation-order reversal on every parse. This keeps parsed-game
382
- # serialization byte-compatible with the legacy behavior; the quirk
383
- # can be fixed in a separate change.
384
- result = val[1] << val[0]
380
+ # Left-recursive; the legacy variation-order reversal is applied as
381
+ # a single explicit `.reverse` where the list is consumed in
382
+ # {element}, instead of being implicit in recursion direction.
383
+ result = val[0] << val[1]
385
384
 
386
385
  result
387
386
  end
388
387
  .,.,
389
388
 
390
- module_eval(<<'.,.,', 'pgn_parser.y', 93)
389
+ module_eval(<<'.,.,', 'pgn_parser.y', 92)
391
390
  def _reduce_24(val, _values, result)
392
391
  result = val[1]
393
392
  result
data/lib/pgn/pgn_parser.y CHANGED
@@ -15,7 +15,7 @@ rule
15
15
  {
16
16
  result = val[1].pop
17
17
  result = {
18
- tags: val[0],
18
+ tags: val[0].reverse_each.with_object({}) { |pair, h| h.merge!(pair) },
19
19
  result: result,
20
20
  moves: val[1],
21
21
  pgn: nil,
@@ -24,14 +24,14 @@ rule
24
24
  }
25
25
 
26
26
  tag_section:
27
- tag_pair { result = val[0] }
28
- | tag_pair tag_section
27
+ tag_pair { result = [val[0]] }
28
+ | tag_section tag_pair
29
29
  {
30
- # Right-recursive with section.merge(pair) reproduces the legacy
31
- # whittle parser's tag semantics exactly: reverse source insertion
32
- # order, and first-occurrence-wins on duplicate keys. This keeps
33
- # serialized tag order byte-compatible with the legacy behavior.
34
- result = val[1].merge(val[0])
30
+ # Left-recursive; the source-order array is reduced to the legacy
31
+ # tag semantics in {pgn_game} (reverse, then merge with
32
+ # first-occurrence-wins) so the whittle-order compatibility quirk is
33
+ # a single explicit line instead of implicit in recursion direction.
34
+ result = val[0] << val[1]
35
35
  }
36
36
 
37
37
  tag_pair:
@@ -53,7 +53,7 @@ rule
53
53
  | san_move_annotated variation_list
54
54
  {
55
55
  result = val[0]
56
- result.variations = val[1]
56
+ result.variations = val[1].reverse
57
57
  result
58
58
  }
59
59
  | COMMENT
@@ -81,13 +81,12 @@ rule
81
81
 
82
82
  variation_list:
83
83
  variation { result = [val[0]] }
84
- | variation variation_list
84
+ | variation_list variation
85
85
  {
86
- # Right-recursive prepend reproduces the legacy whittle parser's
87
- # variation-order reversal on every parse. This keeps parsed-game
88
- # serialization byte-compatible with the legacy behavior; the quirk
89
- # can be fixed in a separate change.
90
- result = val[1] << val[0]
86
+ # Left-recursive; the legacy variation-order reversal is applied as
87
+ # a single explicit `.reverse` where the list is consumed in
88
+ # {element}, instead of being implicit in recursion direction.
89
+ result = val[0] << val[1]
91
90
  }
92
91
 
93
92
  variation:
data/lib/pgn/position.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module PGN
2
4
  # {PGN::Position} encapsulates all of the information necessary to
3
5
  # completely understand a chess position. It can be turned into a FEN string
@@ -31,12 +33,7 @@ module PGN
31
33
  PLAYERS = %i[white black].freeze
32
34
  CASTLING = %w[K Q k q].freeze
33
35
 
34
- attr_accessor :board
35
- attr_accessor :player
36
- attr_accessor :castling
37
- attr_accessor :en_passant
38
- attr_accessor :halfmove
39
- attr_accessor :fullmove
36
+ attr_accessor :board, :player, :castling, :en_passant, :halfmove, :fullmove
40
37
 
41
38
  # @return [PGN::Position] the starting position of a chess game
42
39
  #
@@ -83,17 +80,10 @@ module PGN
83
80
 
84
81
  restrictions = calculator.castling_restrictions
85
82
  new_castling = restrictions.empty? ? castling : castling - restrictions
86
- new_halfmove = if calculator.increment_halfmove?
87
- halfmove + 1
88
- else
89
- 0
90
- end
91
- new_fullmove = if calculator.increment_fullmove?
92
- fullmove + 1
93
- else
94
- fullmove
95
- end
96
- no_move = str == '--'
83
+ new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0
84
+ new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove
85
+ no_move = str == '--'
86
+
97
87
  PGN::Position.new(
98
88
  no_move ? board : calculator.result_board,
99
89
  next_player,
@@ -110,8 +100,153 @@ module PGN
110
100
  player == :white ? :black : :white
111
101
  end
112
102
 
103
+ # The perft node count at +depth+ from this position, computed by the
104
+ # native bitboard engine via a FEN round-trip. Requires the compiled
105
+ # native extension (the shipped gem); raises NameError if it is absent.
106
+ #
107
+ # @param depth [Integer] search depth, >= 0
108
+ # @return [Integer]
109
+ #
110
+ def perft(depth)
111
+ raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0
112
+
113
+ PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth)
114
+ end
115
+
116
+ # All legal moves from this position as sorted UCI strings
117
+ # (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed
118
+ # by the native bitboard engine via a FEN round-trip. Requires the
119
+ # compiled native extension; raises NameError if it is absent.
120
+ #
121
+ # @return [Array<String>] sorted lexicographically
122
+ #
123
+ def legal_moves
124
+ PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves
125
+ end
126
+
127
+ # All legal moves from this position as sorted SAN strings, computed
128
+ # by delegating the native engine's UCI move list through
129
+ # {PGN::Notation.san}. Requires the compiled native extension; raises
130
+ # NameError if it is absent.
131
+ #
132
+ # @return [Array<String>] sorted lexicographically
133
+ #
134
+ def legal_moves_san
135
+ engine = PGN::Bitboard::Engine.new(to_fen.to_s)
136
+ engine.legal_moves.map { |uci| uci_to_san(uci) }.sort
137
+ end
138
+
139
+ # Whether +move+ is legal. Accepts SAN ("Nf3", "e4", "O-O", "a8=Q",
140
+ # "Qxf7#") or UCI ("g1f3", "e2e4", "e1g1", "a7a8q"). Requires the
141
+ # compiled native extension; raises NameError if it is absent.
142
+ #
143
+ # UCI is handed straight to the engine. SAN is resolved against the
144
+ # engine's legal move list: a SAN string is legal only when it points
145
+ # at exactly one legal move (so an ambiguous "Nd2" with two knights
146
+ # is rejected, while "Nbd2"/"Nfd2" are accepted). The engine remains
147
+ # the single source of truth for legality (king safety, pins, etc.).
148
+ #
149
+ # @param move [String] SAN or UCI
150
+ # @return [Boolean]
151
+ def legal?(move)
152
+ engine = PGN::Bitboard::Engine.new(to_fen.to_s)
153
+ return engine.legal?(move) if uci?(move)
154
+
155
+ parsed = PGN::Move.new(move, player)
156
+ return false if parsed.destination.nil? && parsed.castle.nil?
157
+
158
+ candidates = engine.legal_moves.select { |uci| matches_san?(uci, parsed) }
159
+ candidates.size == 1
160
+ rescue StandardError
161
+ false
162
+ end
163
+
113
164
  def inspect
114
- "\n" + board.inspect
165
+ "\n#{board.inspect}"
166
+ end
167
+
168
+ # The color string for the side to move ('w'/'b').
169
+ def mover_color
170
+ player == :white ? 'w' : 'b'
171
+ end
172
+
173
+ # The color string for the opponent of the side to move ('w'/'b').
174
+ def opponent_color
175
+ player == :white ? 'b' : 'w'
176
+ end
177
+
178
+ # Whether the side to move's king is currently in check.
179
+ #
180
+ # @return [Boolean]
181
+ def in_check?
182
+ king = PGN::Attack.king_idx(board, mover_color)
183
+ !king.nil? && PGN::Attack.attacked?(board, king, opponent_color)
184
+ end
185
+
186
+ # The algebraic squares of every piece of the given color that attacks
187
+ # +square+ (algebraic, e.g. "e4"). Defaults to the side to move's
188
+ # opponent, which is useful for check detection.
189
+ #
190
+ # @param square [String] e.g. "e4"
191
+ # @param color [String, nil] 'w' or 'b'; defaults to the opponent color
192
+ # @return [Array<String>] squares attacking +square+
193
+ def attackers(square, color = opponent_color)
194
+ PGN::Attack.attackers(board, board.index_of(square), color)
195
+ end
196
+
197
+ # Whether the side to move has been checkmated (in check and no legal
198
+ # move). Requires the native extension for legal-move enumeration.
199
+ #
200
+ # @return [Boolean]
201
+ def checkmate?
202
+ in_check? && legal_moves.empty?
203
+ end
204
+
205
+ # Whether the side to move has been stalemated (not in check and no
206
+ # legal move). Requires the native extension.
207
+ #
208
+ # @return [Boolean]
209
+ def stalemate?
210
+ !in_check? && legal_moves.empty?
211
+ end
212
+
213
+ # Whether the position has insufficient material to mate. Covers K vs K,
214
+ # K + one minor vs K, and same-colored bishops only.
215
+ #
216
+ # @return [Boolean]
217
+ def insufficient_material?
218
+ non_king = (0...128).each_with_object([]) do |idx, acc|
219
+ next if idx.anybits?(0x88)
220
+
221
+ piece = board.at_index(idx)
222
+ acc << [piece, idx] if piece && piece.upcase != 'K'
223
+ end
224
+ return true if non_king.empty?
225
+ return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase)
226
+
227
+ bishops_same_color?(non_king)
228
+ end
229
+
230
+ # Whether the 50-move rule applies (100 halfmoves since the last pawn
231
+ # move or capture).
232
+ #
233
+ # @return [Boolean]
234
+ def fifty_move?
235
+ halfmove >= 100
236
+ end
237
+
238
+ # The terminal status of this position: :checkmate, :stalemate, or :draw
239
+ # (insufficient material or 50-move rule). nil if the position is still
240
+ # in progress. Threefold repetition requires game history and is
241
+ # answered by {PGN::Game#outcome}.
242
+ #
243
+ # @return [Symbol, nil]
244
+ def outcome
245
+ return :checkmate if checkmate?
246
+ return :stalemate if stalemate?
247
+ return :draw if insufficient_material? || fifty_move?
248
+
249
+ nil
115
250
  end
116
251
 
117
252
  # @return [PGN::FEN] a {PGN::FEN} object representing the current position
@@ -120,11 +255,108 @@ module PGN
120
255
  PGN::FEN.from_attributes(
121
256
  board: board,
122
257
  active: player == :white ? 'w' : 'b',
123
- castling: castling.join(''),
258
+ castling: castling.join,
124
259
  en_passant: en_passant,
125
260
  halfmove: halfmove.to_s,
126
261
  fullmove: fullmove.to_s
127
262
  )
128
263
  end
264
+
265
+ # Positions are equal when their board, side to move, castling rights,
266
+ # and en-passant square match. Halfmove/fullmove counters are ignored
267
+ # (matching threefold-repetition semantics).
268
+ def eql?(other)
269
+ other.is_a?(PGN::Position) &&
270
+ player == other.player &&
271
+ castling == other.castling &&
272
+ en_passant == other.en_passant &&
273
+ zobrist == other.zobrist &&
274
+ board == other.board
275
+ end
276
+
277
+ alias == eql?
278
+
279
+ def hash
280
+ zobrist
281
+ end
282
+
283
+ # The Zobrist hash of the position. Computed lazily on first access and
284
+ # cached, so the replay hot path (which never asks for the hash) pays
285
+ # nothing; consumers like threefold-repetition checks pay one full seed.
286
+ #
287
+ # @return [Integer]
288
+ def zobrist
289
+ @zobrist ||= Zobrist.seed(board, player, castling, en_passant)
290
+ end
291
+
292
+ private
293
+
294
+ # True when +move+ looks like a UCI coordinate string ("e2e4",
295
+ # "e1g1", "a7a8q"), so it can be handed straight to the engine.
296
+ def uci?(move)
297
+ move.is_a?(String) && move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/)
298
+ end
299
+
300
+ # Convert a UCI string to SAN using the current position, for
301
+ # {#legal_moves_san}. Promotion (if present) is passed as the letter.
302
+ def uci_to_san(uci)
303
+ from = uci[0, 2]
304
+ to = uci[2, 2]
305
+ promo = uci[4]
306
+ PGN::Notation.san(self, from, to, promo)
307
+ end
308
+
309
+ # Map a castling side letter from {PGN::Move#castle} to the king's
310
+ # from/to UCI squares for the side to move.
311
+ CASTLE_UCI = {
312
+ 'K' => 'e1g1',
313
+ 'Q' => 'e1c1',
314
+ 'k' => 'e8g8',
315
+ 'q' => 'e8c8'
316
+ }.freeze
317
+ private_constant :CASTLE_UCI
318
+
319
+ # Does the legal UCI move +uci+ match the parsed SAN +move+? A move
320
+ # matches when destination, piece, promotion, and castling all agree,
321
+ # and the origin square satisfies +move+'s disambiguation (if any).
322
+ def matches_san?(uci, move)
323
+ return uci == CASTLE_UCI[move.castle] if move.castle
324
+
325
+ to = uci[2, 2]
326
+ return false if move.destination != to
327
+
328
+ from_idx = board.index_of(uci[0, 2])
329
+ return false if board.at_index(from_idx) != move.piece
330
+
331
+ promo = uci[4]
332
+ return false if move.promotion&.downcase != promo
333
+
334
+ disambiguation_matches?(move.disambiguation, from_idx)
335
+ end
336
+
337
+ # Whether the disambiguation string from SAN (a file, a rank, or a full
338
+ # square) describes the origin square at +from_idx+. nil disambiguation
339
+ # matches any origin (ambiguity is handled by the caller counting matches).
340
+ def disambiguation_matches?(disambiguation, from_idx)
341
+ return true if disambiguation.nil? || disambiguation.empty?
342
+
343
+ file = Board::INDEX_TO_FILE[from_idx & 0x0F]
344
+ rank = Board::INDEX_TO_RANK[from_idx >> 4]
345
+ case disambiguation
346
+ when /\A[a-h]\z/ then file == disambiguation
347
+ when /\A[1-8]\z/ then rank == disambiguation
348
+ else file + rank == disambiguation
349
+ end
350
+ end
351
+
352
+ # Whether +non_king+ is all bishops on squares of the same color (a known
353
+ # insufficient-material draw, e.g. KB vs KB with same-colored bishops).
354
+ def bishops_same_color?(non_king)
355
+ return false unless non_king.all? { |piece, _| piece.upcase == 'B' }
356
+ return false if non_king.size > 2
357
+
358
+ colors = non_king.map { |_piece, idx| ((idx & 0x0F) + (idx >> 4)) % 2 }
359
+ colors.uniq.size == 1
360
+ end
129
361
  end
130
362
  end
@@ -17,7 +17,7 @@ module PGN
17
17
 
18
18
  # @return [String] a canonical PGN string ending with a trailing newline
19
19
  def to_s
20
- tag_section + "\n\n" + movetext_section + "\n"
20
+ "#{tag_section}\n\n#{movetext_section}\n"
21
21
  end
22
22
 
23
23
  private
@@ -39,18 +39,16 @@ module PGN
39
39
  # result, all joined by spaces.
40
40
  def movetext_section
41
41
  tokens = []
42
- if @game.comment && !@game.comment.empty?
43
- tokens << "{ #{escape_comment(@game.comment)} }"
44
- end
42
+ tokens << "{ #{escape_comment(@game.comment)} }" if @game.comment && !@game.comment.empty?
45
43
  line = emit_line(@game.moves, starting_fullmove, starting_player)
46
44
  tokens << line unless line.empty?
47
45
  tokens << result_token
48
- tokens.join(" ")
46
+ tokens.join(' ')
49
47
  end
50
48
 
51
49
  # The result token: the game's result if present and non-empty, else "*".
52
50
  def result_token
53
- (@game.result.nil? || @game.result.empty?) ? "*" : @game.result
51
+ @game.result.nil? || @game.result.empty? ? '*' : @game.result
54
52
  end
55
53
 
56
54
  # Emit a single line (mainline or variation) of movetext, tracking the
@@ -63,12 +61,11 @@ module PGN
63
61
  moves.each do |move|
64
62
  if player == :white
65
63
  tokens << "#{fullmove}."
66
- tokens << move_token(move, fullmove, player)
67
64
  else # black
68
65
  need_number = prev_player.nil? || prev_had_extras || prev_player != :white
69
66
  tokens << "#{fullmove}..." if need_number
70
- tokens << move_token(move, fullmove, player)
71
67
  end
68
+ tokens << move_token(move, fullmove, player)
72
69
 
73
70
  prev_player = player
74
71
  prev_had_extras = has_extras?(move)
@@ -76,7 +73,7 @@ module PGN
76
73
  player = opposite(player)
77
74
  end
78
75
 
79
- tokens.join(" ")
76
+ tokens.join(' ')
80
77
  end
81
78
 
82
79
  # A move token: notation plus trailing extras (annotation, comment,
@@ -85,13 +82,11 @@ module PGN
85
82
  def move_token(move, fullmove, player)
86
83
  parts = [move.notation]
87
84
  (move.annotation || []).each { |a| parts << a }
88
- if move.comment && !move.comment.empty?
89
- parts << "{ #{escape_comment(move.comment)} }"
90
- end
85
+ parts << "{ #{escape_comment(move.comment)} }" if move.comment && !move.comment.empty?
91
86
  (move.variations || []).each do |variation|
92
87
  parts << "(#{emit_line(variation, fullmove, player)})"
93
88
  end
94
- parts.join(" ")
89
+ parts.join(' ')
95
90
  end
96
91
 
97
92
  # Whether a move carries any annotation, comment, or variation.
@@ -118,8 +113,8 @@ module PGN
118
113
  # string replacement would otherwise re-interpret backslashes).
119
114
  def escape_tag(value)
120
115
  value.to_s
121
- .gsub("\\") { "\\\\" }
122
- .gsub('"') { "\\\"" }
116
+ .gsub('\\') { '\\\\' }
117
+ .gsub('"') { '\"' }
123
118
  end
124
119
 
125
120
  # Escape backslashes and braces for a comment body (block form, so the
@@ -133,9 +128,9 @@ module PGN
133
128
  # acknowledged v1 limitation.
134
129
  def escape_comment(text)
135
130
  text.to_s
136
- .gsub("\\") { "\\\\" }
137
- .gsub("{") { "\\{" }
138
- .gsub("}") { "\\}" }
131
+ .gsub('\\') { '\\\\' }
132
+ .gsub('{') { '\\{' }
133
+ .gsub('}') { '\\}' }
139
134
  end
140
135
  end
141
136
  end
data/lib/pgn/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module PGN
2
- VERSION = '1.5.0'.freeze
2
+ VERSION = '2.0.1'.freeze
3
3
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGN
4
+ # Zobrist hashing keys for incremental position hashing. All keys are
5
+ # fixed 64-bit pseudo-random Integers generated once at load time from a
6
+ # frozen seed so hashes are stable for the life of the process.
7
+ #
8
+ # Indexing is by 0x88 square index (0..127); off-board indices are
9
+ # allocated but never read.
10
+ module Zobrist
11
+ SEED = 0x1234_5678_9abc_def1
12
+
13
+ # Deterministic pseudo-random generator so hashes are stable per
14
+ # process and across machines (no Kernel#rand).
15
+ def self.gen
16
+ @gen ||= Random.new(SEED)
17
+ end
18
+ private_class_method :gen
19
+
20
+ def self.rand64
21
+ gen.rand(1 << 64)
22
+ end
23
+ private_class_method :rand64
24
+
25
+ PIECES = %w[P N B R Q K p n b r q k].freeze
26
+
27
+ TABLE = PIECES.to_h { |piece| [piece, Array.new(128) { rand64 }] }.freeze
28
+
29
+ SIDE = rand64
30
+ CASTLING = { 'K' => rand64, 'Q' => rand64, 'k' => rand64, 'q' => rand64 }.freeze
31
+ EP_FILE = Array.new(8) { rand64 }.freeze
32
+
33
+ # @param board [PGN::Board]
34
+ # @param player [Symbol] :white or :black
35
+ # @param castling [Array<String>] e.g. %w[K Q k q]
36
+ # @param en_passant [String, nil] e.g. "e3" or nil
37
+ # @return [Integer] the Zobrist hash of the position
38
+ def self.seed(board, player, castling, en_passant)
39
+ h = 0
40
+ 0.upto(7) do |rank|
41
+ 0.upto(7) do |file|
42
+ idx = board.index_for(file, rank)
43
+ piece = board.at_index(idx)
44
+ h ^= TABLE[piece][idx] if piece
45
+ end
46
+ end
47
+ h ^= SIDE if player == :black
48
+ castling.to_a.each { |right| h ^= CASTLING[right] if CASTLING.key?(right) }
49
+ h ^= EP_FILE[Board::FILE_TO_INDEX[en_passant[0]]] if en_passant && !en_passant.empty?
50
+ h
51
+ end
52
+ end
53
+ end
data/lib/pgn.rb CHANGED
@@ -1,15 +1,20 @@
1
1
  require 'pgn/board'
2
2
  require 'pgn/fen'
3
+ require 'pgn/epd'
3
4
  require 'pgn/game'
5
+ require 'pgn/node'
4
6
  require 'pgn/move'
5
7
  require 'pgn/move_calculator'
6
8
  require 'pgn/lexer'
7
9
  require 'pgn/notation'
10
+ require 'pgn/attack'
11
+ require 'pgn/zobrist'
8
12
  require 'pgn/pgn_parser'
9
13
  require 'pgn/parser'
10
14
  require 'pgn/position'
11
15
  require 'pgn/serializer'
12
16
  require 'pgn/version'
17
+ require 'pgn/bitboard'
13
18
 
14
19
  module PGN
15
20
  # @param pgn [String] a pgn representation of one or more chess games