pgn2 1.5.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 (63) 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 +148 -0
  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 +64 -0
  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 +64 -0
  42. data/lib/pgn/fen.rb +35 -46
  43. data/lib/pgn/game.rb +22 -13
  44. data/lib/pgn/move.rb +19 -15
  45. data/lib/pgn/move_calculator.rb +13 -1
  46. data/lib/pgn/notation.rb +24 -29
  47. data/lib/pgn/position.rb +60 -19
  48. data/lib/pgn/serializer.rb +13 -18
  49. data/lib/pgn/version.rb +1 -1
  50. data/lib/pgn/zobrist.rb +53 -0
  51. data/lib/pgn.rb +2 -0
  52. data/pgn2.gemspec +17 -10
  53. data/spec/bitboard_spec.rb +54 -0
  54. data/spec/board_spec.rb +53 -0
  55. data/spec/fen_spec.rb +65 -65
  56. data/spec/game_spec.rb +52 -15
  57. data/spec/lexer_spec.rb +5 -5
  58. data/spec/notation_spec.rb +5 -0
  59. data/spec/parser_spec.rb +8 -1
  60. data/spec/position_spec.rb +128 -27
  61. data/spec/serializer_spec.rb +4 -4
  62. data/spec/zobrist_spec.rb +46 -0
  63. metadata +97 -35
@@ -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.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
data/spec/fen_spec.rb CHANGED
@@ -1,120 +1,120 @@
1
- require "spec_helper"
1
+ require 'spec_helper'
2
2
 
3
3
  describe PGN::FEN do
4
- describe "castling availability" do
5
- it "should remove all castling availabilitiy after castling" do
6
- pos = PGN::FEN.new("rnbqk2r/1p3pbp/p2p1np1/2pP4/P3PB2/2N2N2/1P3PPP/R2QKB1R b KQkq e3 0 8").to_position
7
- next_pos = pos.move("O-O")
4
+ describe 'castling availability' do
5
+ it 'should remove all castling availabilitiy after castling' do
6
+ pos = PGN::FEN.new('rnbqk2r/1p3pbp/p2p1np1/2pP4/P3PB2/2N2N2/1P3PPP/R2QKB1R b KQkq e3 0 8').to_position
7
+ next_pos = pos.move('O-O')
8
8
  next_pos.to_fen.castling.should_not match(/k|q/)
9
9
  end
10
10
 
11
- it "should remove all castling availability after moving a king" do
12
- pos = PGN::FEN.new("r1b1kb1r/pp2pppp/2n5/4P3/1nB5/P4N2/1P3PPP/RNBqK2R w KQkq - 0 9").to_position
13
- next_pos = pos.move("Kxd1")
11
+ it 'should remove all castling availability after moving a king' do
12
+ pos = PGN::FEN.new('r1b1kb1r/pp2pppp/2n5/4P3/1nB5/P4N2/1P3PPP/RNBqK2R w KQkq - 0 9').to_position
13
+ next_pos = pos.move('Kxd1')
14
14
  next_pos.to_fen.castling.should_not match(/K|Q/)
15
15
  end
16
16
 
17
- it "should remove only the one castling option after moving a rook" do
18
- pos = PGN::FEN.new("r3k2r/1pp2pp1/p1pb1qn1/4p3/3PP1p1/8/PPPN1PPN/R1BQR1K1 b kq - 1 11").to_position
19
- next_pos = pos.move("Rxh2")
17
+ it 'should remove only the one castling option after moving a rook' do
18
+ pos = PGN::FEN.new('r3k2r/1pp2pp1/p1pb1qn1/4p3/3PP1p1/8/PPPN1PPN/R1BQR1K1 b kq - 1 11').to_position
19
+ next_pos = pos.move('Rxh2')
20
20
  next_pos.to_fen.castling.should_not match(/k/)
21
21
  next_pos.to_fen.castling.should match(/q/)
22
22
  end
23
23
 
24
- it "should remove one castling option when a rook is taken" do
25
- pos = PGN::FEN.new("rn1qkbnr/pbpppppp/1p6/8/6P1/2N4P/PPPPPP2/R1BQKBNR b KQkq - 2 3").to_position
26
- next_pos = pos.move("Bxh1")
24
+ it 'should remove one castling option when a rook is taken' do
25
+ pos = PGN::FEN.new('rn1qkbnr/pbpppppp/1p6/8/6P1/2N4P/PPPPPP2/R1BQKBNR b KQkq - 2 3').to_position
26
+ next_pos = pos.move('Bxh1')
27
27
  next_pos.to_fen.castling.should_not match(/K/)
28
28
  end
29
29
 
30
- it "should change to a hyphen once no side can castle" do
31
- pos = PGN::FEN.new("r1bq1rk1/pp1nbppp/3ppn2/8/2PP1N2/P1N5/1P2BPPP/R1BQK2R w KQ - 2 9").to_position
32
- pos.to_fen.castling.should_not == "-"
33
- next_pos = pos.move("O-O")
34
- next_pos.to_fen.castling.should == "-"
30
+ it 'should change to a hyphen once no side can castle' do
31
+ pos = PGN::FEN.new('r1bq1rk1/pp1nbppp/3ppn2/8/2PP1N2/P1N5/1P2BPPP/R1BQK2R w KQ - 2 9').to_position
32
+ pos.to_fen.castling.should_not
33
+ next_pos = pos.move('O-O')
34
+ next_pos.to_fen.castling.should == '-'
35
35
  end
36
36
  end
37
37
 
38
- describe "en passant" do
39
- it "should display the en passant square whenever a pawn moves two spaces" do
40
- pos = PGN::FEN.new("rnbqkb1r/pppppppp/5n2/8/8/5N2/PPPPPPPP/RNBQKB1R w KQkq - 2 1").to_position
41
- next_pos = pos.move("c4")
42
- next_pos.to_fen.en_passant.should == "c3"
38
+ describe 'en passant' do
39
+ it 'should display the en passant square whenever a pawn moves two spaces' do
40
+ pos = PGN::FEN.new('rnbqkb1r/pppppppp/5n2/8/8/5N2/PPPPPPPP/RNBQKB1R w KQkq - 2 1').to_position
41
+ next_pos = pos.move('c4')
42
+ next_pos.to_fen.en_passant.should == 'c3'
43
43
  end
44
44
 
45
- it "should be a hyphen if no pawn moved two spaces the previous move" do
46
- pos = PGN::FEN.new("rnbqkb1r/pppppppp/5n2/8/2P5/5N2/PP1PPPPP/RNBQKB1R b KQkq c3 0 1").to_position
47
- next_pos = pos.move("d6")
48
- next_pos.to_fen.en_passant.should == "-"
45
+ it 'should be a hyphen if no pawn moved two spaces the previous move' do
46
+ pos = PGN::FEN.new('rnbqkb1r/pppppppp/5n2/8/2P5/5N2/PP1PPPPP/RNBQKB1R b KQkq c3 0 1').to_position
47
+ next_pos = pos.move('d6')
48
+ next_pos.to_fen.en_passant.should == '-'
49
49
  end
50
50
  end
51
51
 
52
- describe "halfmove counter" do
53
- it "should reset after a pawn advance" do
54
- pos = PGN::FEN.new("2b2rk1/2pp1ppp/1p6/r3P2q/3Q4/2P5/PP3PPP/RN3RK1 w - - 3 15").to_position
55
- pos.to_fen.halfmove.should == "3"
56
- next_pos = pos.move("f4")
57
- next_pos.to_fen.halfmove.should == "0"
52
+ describe 'halfmove counter' do
53
+ it 'should reset after a pawn advance' do
54
+ pos = PGN::FEN.new('2b2rk1/2pp1ppp/1p6/r3P2q/3Q4/2P5/PP3PPP/RN3RK1 w - - 3 15').to_position
55
+ pos.to_fen.halfmove.should
56
+ next_pos = pos.move('f4')
57
+ next_pos.to_fen.halfmove.should == '0'
58
58
  end
59
59
 
60
- it "should reset after a capture" do
61
- pos = PGN::FEN.new("2r2rk1/1p2ppbp/1q1p1np1/pN4B1/Pnb1PP2/2N5/1PP1B1PP/R2Q1R1K w - - 5 14").to_position
62
- pos.to_fen.halfmove.should == "5"
63
- next_pos = pos.move("Bxc4")
64
- next_pos.to_fen.halfmove.should == "0"
60
+ it 'should reset after a capture' do
61
+ pos = PGN::FEN.new('2r2rk1/1p2ppbp/1q1p1np1/pN4B1/Pnb1PP2/2N5/1PP1B1PP/R2Q1R1K w - - 5 14').to_position
62
+ pos.to_fen.halfmove.should
63
+ next_pos = pos.move('Bxc4')
64
+ next_pos.to_fen.halfmove.should == '0'
65
65
  end
66
66
 
67
- it "should not reset otherwise" do
68
- pos = PGN::FEN.new("2k2b1r/p4p1p/q1p2p2/8/2br4/5P2/PPQB2PP/R1N1K2R w KQ - 0 17").to_position
69
- pos.to_fen.halfmove.should == "0"
70
- moves = %w{Qf5+ Rd7 Bc3 Bh6 Qa5 Re8+ Kf2 Be3+ Kg3 Rg8+ Kh4}
67
+ it 'should not reset otherwise' do
68
+ pos = PGN::FEN.new('2k2b1r/p4p1p/q1p2p2/8/2br4/5P2/PPQB2PP/R1N1K2R w KQ - 0 17').to_position
69
+ pos.to_fen.halfmove.should
70
+ moves = %w[Qf5+ Rd7 Bc3 Bh6 Qa5 Re8+ Kf2 Be3+ Kg3 Rg8+ Kh4]
71
71
  moves.each_with_index do |move, i|
72
72
  pos = pos.move(move)
73
- pos.to_fen.halfmove.should == (i+1).to_s
73
+ pos.to_fen.halfmove.should == (i + 1).to_s
74
74
  end
75
75
  end
76
76
  end
77
77
 
78
- describe "fullmove counter" do
79
- it "should not increase after white moves" do
80
- pos = PGN::FEN.new("4br1k/ppqnr1b1/3p3p/P1pP1p2/2P1pB2/6PP/1P2BP1N/R2QR1K1 w - - 3 25").to_position
81
- pos.to_fen.fullmove.should == "25"
82
- next_pos = pos.move("Qd2")
83
- next_pos.to_fen.fullmove.should == "25"
78
+ describe 'fullmove counter' do
79
+ it 'should not increase after white moves' do
80
+ pos = PGN::FEN.new('4br1k/ppqnr1b1/3p3p/P1pP1p2/2P1pB2/6PP/1P2BP1N/R2QR1K1 w - - 3 25').to_position
81
+ pos.to_fen.fullmove.should
82
+ next_pos = pos.move('Qd2')
83
+ next_pos.to_fen.fullmove.should == '25'
84
84
  end
85
85
 
86
- it "should increase after black moves" do
87
- pos = PGN::FEN.new("4br1k/ppqnr1b1/3p3p/P1pP1p2/2P1pB2/6PP/1P1QBP1N/R3R1K1 b - - 4 25").to_position
88
- pos.to_fen.fullmove.should == "25"
89
- next_pos = pos.move("Kh7")
90
- next_pos.to_fen.fullmove.should == "26"
86
+ it 'should increase after black moves' do
87
+ pos = PGN::FEN.new('4br1k/ppqnr1b1/3p3p/P1pP1p2/2P1pB2/6PP/1P1QBP1N/R3R1K1 b - - 4 25').to_position
88
+ pos.to_fen.fullmove.should
89
+ next_pos = pos.move('Kh7')
90
+ next_pos.to_fen.fullmove.should == '26'
91
91
  end
92
92
  end
93
93
 
94
- describe "displaying FEN notation" do
95
- it "should return a string on inspect" do
94
+ describe 'displaying FEN notation' do
95
+ it 'should return a string on inspect' do
96
96
  fen = PGN::FEN.start
97
97
  fen.inspect.should be_a(String)
98
98
  end
99
99
  end
100
100
 
101
- describe "board_string round-trip" do
101
+ describe 'board_string round-trip' do
102
102
  fens = [
103
103
  PGN::FEN::INITIAL,
104
- "r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6",
105
- "8/8/8/8/8/8/8/8 w - - 0 1",
106
- "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1",
107
- "4k3/4P3/8/8/8/8/8/4K3 w - - 0 1",
108
- "rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3",
104
+ 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6',
105
+ '8/8/8/8/8/8/8/8 w - - 0 1',
106
+ 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1',
107
+ '4k3/4P3/8/8/8/8/8/4K3 w - - 0 1',
108
+ 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
109
109
  ]
110
110
 
111
- it "round-trips every fixture through FEN#to_s" do
111
+ it 'round-trips every fixture through FEN#to_s' do
112
112
  fens.each do |fen|
113
113
  expect(PGN::FEN.new(fen).to_s).to eq(fen), fen
114
114
  end
115
115
  end
116
116
 
117
- it "round-trips the board portion through FEN#to_position.to_fen" do
117
+ it 'round-trips the board portion through FEN#to_position.to_fen' do
118
118
  fens.each do |fen|
119
119
  original = PGN::FEN.new(fen)
120
120
  roundtrip = original.to_position.to_fen
data/spec/game_spec.rb CHANGED
@@ -4,7 +4,11 @@ describe PGN::Game do
4
4
  describe '#positions' do
5
5
  it 'does not raise an error' do
6
6
  tags = { 'White' => 'Deep Blue', 'Black' => 'Kasparov' }
7
- moves = %w[e4 c5 c3 d5 exd5 Qxd5 d4 Nf6 Nf3 Bg4 Be2 e6 h3 Bh5 O-O Nc6 Be3 cxd4 cxd4 Bb4 a3 Ba5 Nc3 Qd6 Nb5 Qe7 Ne5 Bxe2 Qxe2 O-O Rac1 Rac8 Bg5 Bb6 Bxf6 gxf6 Nc4 Rfd8 Nxb6 axb6 Rfd1 f5 Qe3 Qf6 d5 Rxd5 Rxd5 exd5 b3 Kh8 Qxb6 Rg8 Qc5 d4 Nd6 f4 Nxb7 Ne5 Qd5 f3 g3 Nd3 Rc7 Re8 Nd6 Re1+ Kh2 Nxf2 Nxf7+ Kg7 Ng5+ Kh6 Rxh7+]
7
+ moves = %w[e4 c5 c3 d5 exd5 Qxd5 d4 Nf6 Nf3 Bg4 Be2 e6 h3 Bh5 O-O Nc6 Be3 cxd4
8
+ cxd4 Bb4 a3 Ba5 Nc3 Qd6 Nb5 Qe7 Ne5 Bxe2 Qxe2 O-O Rac1 Rac8 Bg5 Bb6
9
+ Bxf6 gxf6 Nc4 Rfd8 Nxb6 axb6 Rfd1 f5 Qe3 Qf6 d5 Rxd5 Rxd5 exd5 b3
10
+ Kh8 Qxb6 Rg8 Qc5 d4 Nd6 f4 Nxb7 Ne5 Qd5 f3 g3 Nd3 Rc7 Re8 Nd6 Re1+
11
+ Kh2 Nxf2 Nxf7+ Kg7 Ng5+ Kh6 Rxh7+]
8
12
  result = '1-0'
9
13
  game = PGN::Game.new(moves, tags, result)
10
14
  expect { game.positions }.not_to raise_error
@@ -19,6 +23,40 @@ describe PGN::Game do
19
23
  end
20
24
  end
21
25
 
26
+ describe '#each_position' do
27
+ it 'yields each position in order when given a block' do
28
+ game = PGN::Game.new(%w[e4 e5])
29
+ yielded = []
30
+ game.each_position { |p| yielded << p.to_fen.to_s }
31
+ expect(yielded).to eq(
32
+ [
33
+ PGN::Position.start.to_fen.to_s,
34
+ game.positions[1].to_fen.to_s,
35
+ game.positions[2].to_fen.to_s
36
+ ]
37
+ )
38
+ end
39
+
40
+ it 'returns an Enumerator when no block is given' do
41
+ game = PGN::Game.new(%w[e4 e5])
42
+ expect(game.each_position).to be_an(Enumerator)
43
+ end
44
+
45
+ it 'produces the same positions as #positions' do
46
+ game = PGN::Game.new(%w[e4 c5 Nf3])
47
+ expect(game.each_position.map { |p| p.to_fen.to_s })
48
+ .to eq(game.positions.map { |p| p.to_fen.to_s })
49
+ end
50
+
51
+ it 'does not materialize the full array when only the last is needed' do
52
+ moves = %w[e4 c5 Nf3 d6]
53
+ game = PGN::Game.new(moves)
54
+ last = nil
55
+ game.each_position { |p| last = p }
56
+ expect(last.to_fen.to_s).to eq(game.positions.last.to_fen.to_s)
57
+ end
58
+ end
59
+
22
60
  describe '#to_pgn' do
23
61
  it 'returns a canonical PGN string ending with a newline' do
24
62
  game = PGN::Game.new(%w[e4 e5], { 'White' => 'A' }, '1-0')
@@ -34,20 +72,20 @@ describe PGN::Game do
34
72
  # *order* does not round-trip; only the set of variations does. We
35
73
  # therefore compare variations order-independently via a canonical
36
74
  # signature.
37
- def move_signature(m)
38
- c = m.comment.to_s
75
+ def move_signature(move)
76
+ c = move.comment.to_s
39
77
  # The current parser does not unescape \\, {, or }, so comments
40
78
  # containing those do not round-trip byte-for-byte (design spec v1
41
79
  # limitation). Treat them as a single sentinel so the set comparison
42
80
  # ignores the difference.
43
- c = "<unescaped-comment>" if c.match?(/[\\{}]/)
44
- vars = (m.variations || []).map { |v| v.map { |mm| move_signature(mm) }.join("\x02") }.sort.join("\x03")
45
- "#{m.notation}\x01#{(m.annotation || []).inspect}\x01#{c}\x01#{vars}"
81
+ c = '<unescaped-comment>' if c.match?(/[\\{}]/)
82
+ vars = (move.variations || []).map { |v| v.map { |sub| move_signature(sub) }.join("\x02") }.sort.join("\x03")
83
+ "#{move.notation}\x01#{(move.annotation || []).inspect}\x01#{c}\x01#{vars}"
46
84
  end
47
85
 
48
86
  def expect_moves_equal(expected, actual, ctx)
49
87
  expect(actual.map(&:notation)).to eq(expected.map(&:notation)),
50
- "#{ctx}: notation"
88
+ "#{ctx}: notation"
51
89
  expected.each_with_index do |e, i|
52
90
  expect(actual[i].annotation).to eq(e.annotation), "#{ctx}[#{i}]: annotation"
53
91
  # The current parser cannot unescape braces, so comments containing
@@ -55,11 +93,11 @@ describe PGN::Game do
55
93
  # limitation). Skip the comparison for those.
56
94
  unless e.comment.to_s.match?(/[\\{}]/)
57
95
  expect(actual[i].comment).to eq(e.comment),
58
- "#{ctx}[#{i}]: comment #{e.comment.inspect} vs #{actual[i].comment.inspect}"
96
+ "#{ctx}[#{i}]: comment #{e.comment.inspect} vs #{actual[i].comment.inspect}"
59
97
  end
60
98
  # Variations compared as an order-independent set of signatures.
61
- exp_sigs = (e.variations || []).map { |v| v.map { |mm| move_signature(mm) }.join("\x02") }.sort
62
- act_sigs = (actual[i].variations || []).map { |v| v.map { |mm| move_signature(mm) }.join("\x02") }.sort
99
+ exp_sigs = (e.variations || []).map { |v| v.map { |sub| move_signature(sub) }.join("\x02") }.sort
100
+ act_sigs = (actual[i].variations || []).map { |v| v.map { |sub| move_signature(sub) }.join("\x02") }.sort
63
101
  expect(act_sigs).to eq(exp_sigs), "#{ctx}[#{i}]: variations"
64
102
  end
65
103
  end
@@ -67,17 +105,16 @@ describe PGN::Game do
67
105
  # Fixtures that do not round-trip byte-for-byte: doublequotes has
68
106
  # unescaped inner quotes (serializer escapes them) and specialcharacters
69
107
  # is multibyte and requires the Encoding::UTF_8 argument to parse.
70
- NON_ROUND_TRIP = %w[doublequotes.pgn specialcharacters.pgn].freeze
71
-
72
108
  it 'round-trips every tracked fixture' do
109
+ non_round_trip = %w[doublequotes.pgn specialcharacters.pgn].freeze
73
110
  fixtures = `git ls-files spec/pgn_files/`.split.map { |p| File.expand_path(p) }
74
- fixtures.reject! { |p| NON_ROUND_TRIP.include?(File.basename(p)) }
111
+ fixtures.reject! { |p| non_round_trip.include?(File.basename(p)) }
75
112
  fixtures.each do |path|
76
113
  PGN.parse(File.read(path)).each_with_index do |game, gi|
77
114
  reparsed = PGN.parse(game.to_pgn).first
78
115
 
79
116
  expect(reparsed.result).to eq(game.result),
80
- "#{path}##{gi}: result"
117
+ "#{path}##{gi}: result"
81
118
  expect_moves_equal(game.moves, reparsed.moves, "#{path}##{gi}")
82
119
 
83
120
  # Reparsed tags must be a superset of the original (a no-tag game
@@ -86,7 +123,7 @@ describe PGN::Game do
86
123
  reparsed_tags = reparsed.tags || {}
87
124
  original_tags.each do |k, v|
88
125
  expect(reparsed_tags[k]).to eq(v),
89
- "#{path}##{gi}: tag #{k} #{v.inspect} vs #{reparsed_tags[k].inspect}"
126
+ "#{path}##{gi}: tag #{k} #{v.inspect} vs #{reparsed_tags[k].inspect}"
90
127
  end
91
128
  end
92
129
  end