pgn2 1.4.0 → 2.0.0

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 (64) 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 +40 -0
  6. data/.github/workflows/release.yml +52 -5
  7. data/.gitignore +9 -1
  8. data/.rubocop.yml +46 -6
  9. data/CHANGELOG.md +183 -1
  10. data/Gemfile +3 -0
  11. data/NOTICE.md +21 -0
  12. data/README.md +107 -5
  13. data/Rakefile +35 -10
  14. data/TODO.md +102 -31
  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/specs/2026-08-13-attack-masks-design.md +57 -0
  27. data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
  28. data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
  29. data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
  30. data/ext/pgn2_native/Cargo.lock +321 -0
  31. data/ext/pgn2_native/Cargo.toml +19 -0
  32. data/ext/pgn2_native/extconf.rb +8 -0
  33. data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
  34. data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
  35. data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
  36. data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
  37. data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
  38. data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
  39. data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
  40. data/lib/pgn/bitboard.rb +13 -0
  41. data/lib/pgn/board.rb +103 -10
  42. data/lib/pgn/fen.rb +35 -46
  43. data/lib/pgn/game.rb +22 -13
  44. data/lib/pgn/lexer.rb +9 -6
  45. data/lib/pgn/move.rb +19 -15
  46. data/lib/pgn/move_calculator.rb +46 -20
  47. data/lib/pgn/notation.rb +24 -29
  48. data/lib/pgn/position.rb +60 -19
  49. data/lib/pgn/serializer.rb +13 -18
  50. data/lib/pgn/version.rb +1 -1
  51. data/lib/pgn/zobrist.rb +53 -0
  52. data/lib/pgn.rb +2 -0
  53. data/pgn2.gemspec +17 -10
  54. data/spec/bitboard_spec.rb +54 -0
  55. data/spec/board_spec.rb +53 -0
  56. data/spec/fen_spec.rb +65 -65
  57. data/spec/game_spec.rb +52 -15
  58. data/spec/lexer_spec.rb +5 -5
  59. data/spec/notation_spec.rb +5 -0
  60. data/spec/parser_spec.rb +8 -1
  61. data/spec/position_spec.rb +128 -27
  62. data/spec/serializer_spec.rb +4 -4
  63. data/spec/zobrist_spec.rb +46 -0
  64. metadata +101 -36
data/lib/pgn/notation.rb CHANGED
@@ -133,7 +133,7 @@ module PGN
133
133
  # Other squares holding `piece` whose move to `to_idx` is legal.
134
134
  def same_type_legals(piece, from_idx, to_idx)
135
135
  (0...128).each_with_object([]) do |idx, acc|
136
- next if (idx & 0x88) != 0
136
+ next if idx.anybits?(0x88)
137
137
  next if idx == from_idx
138
138
  next unless @board.at_index(idx) == piece
139
139
 
@@ -230,9 +230,9 @@ module PGN
230
230
 
231
231
  case piece.upcase
232
232
  when 'N'
233
- KNIGHT_OFFS.any? { |o| (from_idx + o) == to_idx }
233
+ Board::KNIGHT_ATTACKS[from_idx].include?(to_idx)
234
234
  when 'K'
235
- KING_OFFS.any? { |o| (from_idx + o) == to_idx }
235
+ Board::KING_ATTACKS[from_idx].include?(to_idx)
236
236
  when 'B', 'R', 'Q'
237
237
  slider_reaches?(piece.upcase, from_idx, to_idx)
238
238
  else
@@ -243,7 +243,7 @@ module PGN
243
243
  def slider_reaches?(piece_up, from_idx, to_idx)
244
244
  slider_dirs(piece_up).any? do |off|
245
245
  i = from_idx + off
246
- while (i & 0x88).zero?
246
+ while i.nobits?(0x88)
247
247
  return true if i == to_idx
248
248
  break if @board.at_index(i)
249
249
 
@@ -264,7 +264,7 @@ module PGN
264
264
  end
265
265
 
266
266
  def own?(piece, color)
267
- color == 'w' ? piece == piece.upcase : piece == piece.downcase
267
+ piece == (color == 'w' ? piece.upcase : piece.downcase)
268
268
  end
269
269
 
270
270
  # Slider ray directions for a piece letter ('B'/'R'/'Q').
@@ -281,7 +281,7 @@ module PGN
281
281
  def king_idx(board, color)
282
282
  king = color == 'w' ? 'K' : 'k'
283
283
  (0...128).each do |idx|
284
- next if (idx & 0x88) != 0
284
+ next if idx.anybits?(0x88)
285
285
 
286
286
  return idx if board.at_index(idx) == king
287
287
  end
@@ -301,24 +301,18 @@ module PGN
301
301
  pawn = color == 'w' ? 'P' : 'p'
302
302
  offs.any? do |off|
303
303
  i = target + off
304
- (i & 0x88).zero? && board.at_index(i) == pawn
304
+ i.nobits?(0x88) && board.at_index(i) == pawn
305
305
  end
306
306
  end
307
307
 
308
308
  def knight_attacked?(board, target, color)
309
309
  knight = color == 'w' ? 'N' : 'n'
310
- KNIGHT_OFFS.any? do |off|
311
- i = target + off
312
- (i & 0x88).zero? && board.at_index(i) == knight
313
- end
310
+ Board::KNIGHT_ATTACKS[target].any? { |i| board.at_index(i) == knight }
314
311
  end
315
312
 
316
313
  def king_attacked?(board, target, color)
317
314
  king = color == 'w' ? 'K' : 'k'
318
- KING_OFFS.any? do |off|
319
- i = target + off
320
- (i & 0x88).zero? && board.at_index(i) == king
321
- end
315
+ Board::KING_ATTACKS[target].any? { |i| board.at_index(i) == king }
322
316
  end
323
317
 
324
318
  def slider_attacked?(board, target, color)
@@ -332,10 +326,11 @@ module PGN
332
326
  def ray_hit?(board, target, dirs)
333
327
  dirs.each do |off|
334
328
  i = target + off
335
- while (i & 0x88).zero?
329
+ while i.nobits?(0x88)
336
330
  piece = board.at_index(i)
337
331
  if piece
338
332
  return true if yield(piece)
333
+
339
334
  break
340
335
  end
341
336
  i += off
@@ -352,7 +347,7 @@ module PGN
352
347
  ep_idx = ep_target_idx(position, board)
353
348
 
354
349
  (0...128).any? do |idx|
355
- next if (idx & 0x88) != 0
350
+ next if idx.anybits?(0x88)
356
351
 
357
352
  piece = board.at_index(idx)
358
353
  piece && own?(piece, color) && move_from?(board, idx, piece, color, ep_idx)
@@ -370,8 +365,8 @@ module PGN
370
365
  def move_from?(board, idx, piece, color, ep_idx)
371
366
  up = piece.upcase
372
367
  case up
373
- when 'N' then leaper_moves?(board, idx, piece, color, KNIGHT_OFFS)
374
- when 'K' then leaper_moves?(board, idx, piece, color, KING_OFFS)
368
+ when 'N' then leaper_moves?(board, idx, piece, color, Board::KNIGHT_ATTACKS[idx])
369
+ when 'K' then leaper_moves?(board, idx, piece, color, Board::KING_ATTACKS[idx])
375
370
  when 'B', 'R', 'Q'
376
371
  slider_moves?(board, idx, piece, color, slider_dirs(up))
377
372
  when 'P'
@@ -381,22 +376,22 @@ module PGN
381
376
  end
382
377
  end
383
378
 
384
- def leaper_moves?(board, idx, piece, color, offs)
385
- offs.any? do |off|
386
- t = idx + off
387
- (t & 0x88).zero? && landable?(board, t, color) && safe?(board, idx, t, piece, nil)
379
+ def leaper_moves?(board, idx, piece, color, targets)
380
+ targets.any? do |t|
381
+ landable?(board, t, color) && safe?(board, idx, t, piece, nil)
388
382
  end
389
383
  end
390
384
 
391
385
  def slider_moves?(board, idx, piece, color, dirs)
392
386
  dirs.any? do |off|
393
387
  t = idx + off
394
- while (t & 0x88).zero?
388
+ while t.nobits?(0x88)
395
389
  tp = board.at_index(t)
396
390
  if tp.nil?
397
391
  return true if safe?(board, idx, t, piece, nil)
398
392
  else
399
393
  return true if !own?(tp, color) && safe?(board, idx, t, piece, nil)
394
+
400
395
  break
401
396
  end
402
397
  t += off
@@ -416,24 +411,24 @@ module PGN
416
411
  promo_rank = color == 'w' ? 7 : 0
417
412
 
418
413
  t = idx + dir
419
- return false unless (t & 0x88).zero? && board.at_index(t).nil?
414
+ return false unless t.nobits?(0x88) && board.at_index(t).nil?
420
415
 
421
- promo = (t >> 4 == promo_rank) ? 'Q' : nil
416
+ promo = t >> 4 == promo_rank ? 'Q' : nil
422
417
  return true if safe?(board, idx, t, piece, promo)
423
418
 
424
419
  t2 = idx + (dir * 2)
425
420
  (idx >> 4) == start_rank && board.at_index(t2).nil? && safe?(board, idx, t2, piece, nil)
426
421
  end
427
422
 
428
- def pawn_captures?(board, idx, piece, color, ep_idx) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
423
+ def pawn_captures?(board, idx, piece, color, ep_idx)
429
424
  promo_rank = color == 'w' ? 7 : 0
430
425
  caps = color == 'w' ? [15, 17] : [-15, -17]
431
426
  caps.any? do |off|
432
427
  t = idx + off
433
- next false unless (t & 0x88).zero?
428
+ next false unless t.nobits?(0x88)
434
429
 
435
430
  tp = board.at_index(t)
436
- promo = (t >> 4 == promo_rank) ? 'Q' : nil
431
+ promo = t >> 4 == promo_rank ? 'Q' : nil
437
432
  if tp && !own?(tp, color)
438
433
  safe?(board, idx, t, piece, promo)
439
434
  elsif tp.nil? && t == ep_idx
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,32 @@ 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
+
113
127
  def inspect
114
- "\n" + board.inspect
128
+ "\n#{board.inspect}"
115
129
  end
116
130
 
117
131
  # @return [PGN::FEN] a {PGN::FEN} object representing the current position
@@ -120,11 +134,38 @@ module PGN
120
134
  PGN::FEN.from_attributes(
121
135
  board: board,
122
136
  active: player == :white ? 'w' : 'b',
123
- castling: castling.join(''),
137
+ castling: castling.join,
124
138
  en_passant: en_passant,
125
139
  halfmove: halfmove.to_s,
126
140
  fullmove: fullmove.to_s
127
141
  )
128
142
  end
143
+
144
+ # Positions are equal when their board, side to move, castling rights,
145
+ # and en-passant square match. Halfmove/fullmove counters are ignored
146
+ # (matching threefold-repetition semantics).
147
+ def eql?(other)
148
+ other.is_a?(PGN::Position) &&
149
+ player == other.player &&
150
+ castling == other.castling &&
151
+ en_passant == other.en_passant &&
152
+ zobrist == other.zobrist &&
153
+ board == other.board
154
+ end
155
+
156
+ alias == eql?
157
+
158
+ def hash
159
+ zobrist
160
+ end
161
+
162
+ # The Zobrist hash of the position. Computed lazily on first access and
163
+ # cached, so the replay hot path (which never asks for the hash) pays
164
+ # nothing; consumers like threefold-repetition checks pay one full seed.
165
+ #
166
+ # @return [Integer]
167
+ def zobrist
168
+ @zobrist ||= Zobrist.seed(board, player, castling, en_passant)
169
+ end
129
170
  end
130
171
  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.4.0'.freeze
2
+ VERSION = '2.0.0'.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
@@ -5,11 +5,13 @@ require 'pgn/move'
5
5
  require 'pgn/move_calculator'
6
6
  require 'pgn/lexer'
7
7
  require 'pgn/notation'
8
+ require 'pgn/zobrist'
8
9
  require 'pgn/pgn_parser'
9
10
  require 'pgn/parser'
10
11
  require 'pgn/position'
11
12
  require 'pgn/serializer'
12
13
  require 'pgn/version'
14
+ require 'pgn/bitboard'
13
15
 
14
16
  module PGN
15
17
  # @param pgn [String] a pgn representation of one or more chess games
data/pgn2.gemspec CHANGED
@@ -1,3 +1,4 @@
1
+ require 'English'
1
2
  lib = File.expand_path('lib', __dir__)
2
3
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
4
  require 'pgn/version'
@@ -10,27 +11,33 @@ Gem::Specification.new do |spec|
10
11
  spec.description = 'A PGN parser and FEN generator for Ruby'
11
12
  spec.summary = 'A PGN parser for Ruby'
12
13
  spec.homepage = 'https://github.com/muriloime/pgn'
13
- spec.license = 'MIT'
14
+ spec.licenses = ['MIT', 'MPL-2.0']
14
15
  spec.required_ruby_version = '>= 3.0'
15
16
 
16
17
  spec.metadata = {
17
- 'homepage_uri' => 'https://github.com/muriloime/pgn',
18
- 'source_code_uri' => 'https://github.com/muriloime/pgn',
19
- 'bug_tracker_uri' => 'https://github.com/muriloime/pgn/issues',
20
- 'changelog_uri' => 'https://github.com/muriloime/pgn/blob/main/CHANGELOG.md'
18
+ 'homepage_uri' => 'https://github.com/muriloime/pgn',
19
+ 'source_code_uri' => 'https://github.com/muriloime/pgn',
20
+ 'bug_tracker_uri' => 'https://github.com/muriloime/pgn/issues',
21
+ 'changelog_uri' => 'https://github.com/muriloime/pgn/blob/main/CHANGELOG.md',
22
+ 'rubygems_mfa_required' => 'true'
21
23
  }
22
24
 
23
- spec.files = `git ls-files`.split($/)
25
+ spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
24
26
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
25
- spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
26
27
  spec.require_paths = ['lib']
27
28
 
29
+ spec.extensions = ['ext/pgn2_native/extconf.rb']
30
+ spec.add_dependency 'rb_sys', '~> 0.9.39'
31
+ spec.add_development_dependency 'rake-compiler', '~> 1.2'
32
+ spec.add_development_dependency 'rake-compiler-dock', '~> 1.6'
33
+
34
+ spec.add_development_dependency 'benchmark-ips'
28
35
  spec.add_development_dependency 'bundler', '~> 2.3'
36
+ spec.add_development_dependency 'bundler-audit'
37
+ spec.add_development_dependency 'memory_profiler'
29
38
  spec.add_development_dependency 'pry'
39
+ spec.add_development_dependency 'racc'
30
40
  spec.add_development_dependency 'rake'
31
41
  spec.add_development_dependency 'rspec'
32
- spec.add_development_dependency 'racc'
33
- spec.add_development_dependency 'benchmark-ips'
34
- spec.add_development_dependency 'memory_profiler'
35
42
  spec.add_development_dependency 'rubocop', '~> 1.60'
36
43
  end
@@ -0,0 +1,54 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe PGN::Bitboard::Engine do
4
+ let(:startpos) { 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' }
5
+
6
+ # The native extension is optional from the gem's perspective; skip the
7
+ # whole suite if it isn't compiled in this environment.
8
+ skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
9
+
10
+ it 'perfts the start position' do
11
+ e = described_class.new(startpos)
12
+ expect(e.perft(1)).to eq(20)
13
+ expect(e.perft(2)).to eq(400)
14
+ expect(e.perft(3)).to eq(8902)
15
+ expect(e.perft(4)).to eq(197_281)
16
+ end
17
+
18
+ it 'perfts Kiwipete' do
19
+ e = described_class.new('r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1')
20
+ expect(e.perft(1)).to eq(48)
21
+ expect(e.perft(3)).to eq(97_862)
22
+ end
23
+
24
+ it 'raises on a bad FEN' do
25
+ expect { described_class.new('not a fen') }.to raise_error(ArgumentError)
26
+ end
27
+
28
+ it 'lists legal moves in sorted UCI' do
29
+ e = described_class.new(startpos)
30
+ moves = e.legal_moves
31
+ expect(moves.length).to eq(20)
32
+ expect(moves).to eq(moves.sort)
33
+ expect(moves).to include('e2e4', 'g1f3', 'd2d4')
34
+ end
35
+
36
+ it 'answers legal? with UCI' do
37
+ e = described_class.new(startpos)
38
+ expect(e.legal?('e2e4')).to be(true)
39
+ expect(e.legal?('e2e5')).to be(false)
40
+ expect(e.legal?('g1f3')).to be(true)
41
+ end
42
+
43
+ it 'encodes promotions in legal_moves' do
44
+ e = described_class.new('8/P7/8/8/8/8/8/4k2K w - - 0 1')
45
+ moves = e.legal_moves
46
+ expect(moves).to include('a7a8q', 'a7a8r', 'a7a8b', 'a7a8n')
47
+ end
48
+
49
+ it 'recognizes castling as legal' do
50
+ e = described_class.new('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1')
51
+ expect(e.legal?('e1g1')).to be(true)
52
+ expect(e.legal?('e1c1')).to be(true)
53
+ end
54
+ end
data/spec/board_spec.rb CHANGED
@@ -7,6 +7,36 @@ describe PGN::Board do
7
7
  end
8
8
  end
9
9
 
10
+ describe 'attack masks' do
11
+ it 'KNIGHT_ATTACKS lists the on-board knight targets for each 0x88 index' do
12
+ expect(PGN::Board::KNIGHT_ATTACKS[0]).to contain_exactly(18, 33) # a1: c3(0x12), b3(0x21)
13
+ expect(PGN::Board::KNIGHT_ATTACKS[0x54].length).to eq(8) # e4 = 0x54, centre
14
+ expect(PGN::Board::KNIGHT_ATTACKS[0x77]).to contain_exactly(86, 101) # h8: f7(0x56), g6(0x65)
15
+ end
16
+
17
+ it 'KING_ATTACKS lists the on-board king targets for each 0x88 index' do
18
+ expect(PGN::Board::KING_ATTACKS[0]).to contain_exactly(1, 16, 17) # a1: b1, a2, b2
19
+ expect(PGN::Board::KING_ATTACKS[0x54].length).to eq(8)
20
+ expect(PGN::Board::KING_ATTACKS[0x77]).to contain_exactly(102, 103, 118) # h8: g7, h7, g8
21
+ end
22
+
23
+ it 'only contains on-board squares and is nil for off-board source indices' do
24
+ on_board = (0...128).select { |i| i.nobits?(0x88) }
25
+ expect(PGN::Board::KNIGHT_ATTACKS.length).to eq(128)
26
+ expect(PGN::Board::KING_ATTACKS.length).to eq(128)
27
+ on_board.each do |i|
28
+ expect(PGN::Board::KNIGHT_ATTACKS[i]).to be_an(Array)
29
+ expect(PGN::Board::KING_ATTACKS[i]).to be_an(Array)
30
+ expect(PGN::Board::KNIGHT_ATTACKS[i]).to all(satisfy { |sq| sq.nobits?(0x88) })
31
+ expect(PGN::Board::KING_ATTACKS[i]).to all(satisfy { |sq| sq.nobits?(0x88) })
32
+ end
33
+ (0...128).reject { |i| i.nobits?(0x88) }.each do |i|
34
+ expect(PGN::Board::KNIGHT_ATTACKS[i]).to be_nil
35
+ expect(PGN::Board::KING_ATTACKS[i]).to be_nil
36
+ end
37
+ end
38
+ end
39
+
10
40
  describe '#at' do
11
41
  # Use .dup because PGN::Board.start returns the same shared board
12
42
  # backed by the frozen START constant.
@@ -100,6 +130,29 @@ describe PGN::Board do
100
130
  end
101
131
  end
102
132
 
133
+ describe '#fen_board_string' do
134
+ it 'serializes the start position to the FEN board string' do
135
+ expect(PGN::Board.start.fen_board_string)
136
+ .to eq('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR')
137
+ end
138
+
139
+ it 'collapses runs of empty squares into digits' do
140
+ board = PGN::FEN.new('8/8/8/8/8/8/8/8 w - - 0 1').board
141
+ expect(board.fen_board_string).to eq('8/8/8/8/8/8/8/8')
142
+ end
143
+
144
+ it 'serializes a mid-game board byte-for-byte like FEN#board_string' do
145
+ fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
146
+ board = PGN::FEN.new(fen).board
147
+ expect(board.fen_board_string).to eq(fen.split.first)
148
+ end
149
+
150
+ it 'serializes rank 8 first and rank 1 last (FEN order)' do
151
+ board = PGN::FEN.new('4k3/8/8/8/8/8/8/4K3 w - - 0 1').board
152
+ expect(board.fen_board_string).to eq('4k3/8/8/8/8/8/8/4K3')
153
+ end
154
+ end
155
+
103
156
  describe '#inspect' do
104
157
  it 'returns a string with unicode pieces and newlines' do
105
158
  inspected = PGN::Board.start.inspect