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/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
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'UCI-style castling normalization' do
6
+ let(:fen) { 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1' }
7
+ let(:pos) { PGN::FEN.new(fen).to_position }
8
+
9
+ it 'parses 0-0 / 0-0-0 the same as O-O / O-O-O in PGN::Move' do
10
+ expect(PGN::Move.new('0-0', :white).castle).to eq('K')
11
+ expect(PGN::Move.new('0-0-0', :white).castle).to eq('Q')
12
+ expect(PGN::Move.new('0-0', :black).castle).to eq('k')
13
+ expect(PGN::Move.new('0-0-0', :black).castle).to eq('q')
14
+ expect(PGN::Move.new('0-0', :white).piece).to be_nil
15
+ end
16
+
17
+ it 'normalizes parsed 0-0 movetext to O-O on a game' do
18
+ game = PGN::Game.new(%w[0-0])
19
+ expect(game.moves.first.notation).to eq('O-O')
20
+ end
21
+
22
+ it 'Notation.san produces O-O / O-O-O for the king two-file moves' do
23
+ expect(PGN::Notation.san(pos, 'e1', 'g1')).to eq('O-O')
24
+ expect(PGN::Notation.san(pos, 'e1', 'c1')).to eq('O-O-O')
25
+ end
26
+
27
+ it 'Position#legal? accepts both O-O and UCI e1g1 castling' do
28
+ expect(pos.legal?('O-O')).to be(true)
29
+ expect(pos.legal?('O-O-O')).to be(true)
30
+ expect(pos.legal?('e1g1')).to be(true)
31
+ expect(pos.legal?('e1c1')).to be(true)
32
+ end
33
+
34
+ it 'serializes castling as O-O, never 0-0' do
35
+ game = PGN::Game.new(%w[e4 e5 O-O])
36
+ expect(game.to_pgn).to include('O-O')
37
+ expect(game.to_pgn).not_to include('0-0')
38
+ end
39
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'comment round-tripping' do
6
+ it 'round-trips nested braces through parse -> serialize -> parse' do
7
+ input = +"[White \"A\"]\n\n1. e4 {this {is a} nested {comment}} *\n"
8
+ g1 = PGN.parse(input, Encoding::UTF_8).first
9
+ g2 = PGN.parse(g1.to_pgn, Encoding::UTF_8).first
10
+ expect(g2.moves.first.comment).to eq(g1.moves.first.comment)
11
+ expect(g2.moves.first.comment).to eq('this {is a} nested {comment}')
12
+ end
13
+
14
+ it 'is stable: serialize -> parse -> serialize is idempotent' do
15
+ input = +"[White \"A\"]\n\n1. e4 {this {is a} nested {comment}} *\n"
16
+ once = PGN.parse(input, Encoding::UTF_8).first.to_pgn
17
+ twice = PGN.parse(once, Encoding::UTF_8).first.to_pgn
18
+ expect(twice).to eq(once)
19
+ end
20
+
21
+ it 'round-trips escaped braces and backslashes' do
22
+ input = +"[White \"A\"]\n\n1. e4 {a \\{literal\\} back\\\\slash} *\n"
23
+ g = PGN.parse(input, Encoding::UTF_8).first
24
+ expect(g.moves.first.comment).to eq('a {literal} back\\slash')
25
+ reparsed = PGN.parse(g.to_pgn, Encoding::UTF_8).first
26
+ expect(reparsed.moves.first.comment).to eq(g.moves.first.comment)
27
+ end
28
+
29
+ it 'round-trips the nested_comments fixture comment body' do
30
+ input = File.binread('spec/pgn_files/nested_comments.pgn')
31
+ g = PGN.parse(input, Encoding::UTF_8).first
32
+ reparsed = PGN.parse(g.to_pgn, Encoding::UTF_8).first
33
+ expect(reparsed.moves.map(&:comment)).to eq(g.moves.map(&:comment))
34
+ end
35
+ end
data/spec/epd_spec.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PGN::EPD do
6
+ let(:start_epd) { 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -' }
7
+
8
+ it 'parses the placement/side/castling/ep fields' do
9
+ epd = PGN::EPD.new(start_epd)
10
+ pos = epd.to_position
11
+ expect(pos.player).to eq(:white)
12
+ expect(pos.castling).to eq(%w[K Q k q])
13
+ expect(pos.en_passant).to be_nil
14
+ expect(pos.halfmove).to eq(0)
15
+ expect(pos.fullmove).to eq(1)
16
+ end
17
+
18
+ it 'round-trips a simple position' do
19
+ expect(PGN::EPD.new(start_epd).to_s).to eq(start_epd)
20
+ end
21
+
22
+ it 'preserves trailing operation fields verbatim' do
23
+ epd = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - bm e4; id "start";'
24
+ expect(PGN::EPD.new(epd).to_s).to eq(epd)
25
+ expect(PGN::EPD.new(epd).ops).to eq('bm e4; id "start";')
26
+ end
27
+
28
+ it 'normalizes nil castling/ep to "-"' do
29
+ epd = PGN::EPD.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w')
30
+ expect(epd.castling).to eq('-')
31
+ expect(epd.en_passant).to eq('-')
32
+ end
33
+ end
34
+
35
+ RSpec.describe PGN::FEN, '#to_epd' do
36
+ it 'drops the move counters from the start position' do
37
+ expect(PGN::FEN.start.to_epd).to eq('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -')
38
+ end
39
+
40
+ it 'preserves castling and en passant' do
41
+ fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
42
+ expect(PGN::FEN.new(fen).to_epd).to eq('rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6')
43
+ end
44
+ end
data/spec/fen_spec.rb CHANGED
@@ -1,120 +1,126 @@
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
+ end
50
+
51
+ it 'preserves the en passant square through FEN#to_position' do
52
+ fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
53
+ expect(PGN::FEN.new(fen).to_position.en_passant).to eq('d6')
54
+ expect(PGN::FEN.new(fen).to_position.to_fen.to_s).to eq(fen)
49
55
  end
50
56
  end
51
57
 
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"
58
+ describe 'halfmove counter' do
59
+ it 'should reset after a pawn advance' do
60
+ pos = PGN::FEN.new('2b2rk1/2pp1ppp/1p6/r3P2q/3Q4/2P5/PP3PPP/RN3RK1 w - - 3 15').to_position
61
+ pos.to_fen.halfmove.should
62
+ next_pos = pos.move('f4')
63
+ next_pos.to_fen.halfmove.should == '0'
58
64
  end
59
65
 
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"
66
+ it 'should reset after a capture' do
67
+ pos = PGN::FEN.new('2r2rk1/1p2ppbp/1q1p1np1/pN4B1/Pnb1PP2/2N5/1PP1B1PP/R2Q1R1K w - - 5 14').to_position
68
+ pos.to_fen.halfmove.should
69
+ next_pos = pos.move('Bxc4')
70
+ next_pos.to_fen.halfmove.should == '0'
65
71
  end
66
72
 
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}
73
+ it 'should not reset otherwise' do
74
+ pos = PGN::FEN.new('2k2b1r/p4p1p/q1p2p2/8/2br4/5P2/PPQB2PP/R1N1K2R w KQ - 0 17').to_position
75
+ pos.to_fen.halfmove.should
76
+ moves = %w[Qf5+ Rd7 Bc3 Bh6 Qa5 Re8+ Kf2 Be3+ Kg3 Rg8+ Kh4]
71
77
  moves.each_with_index do |move, i|
72
78
  pos = pos.move(move)
73
- pos.to_fen.halfmove.should == (i+1).to_s
79
+ pos.to_fen.halfmove.should == (i + 1).to_s
74
80
  end
75
81
  end
76
82
  end
77
83
 
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"
84
+ describe 'fullmove counter' do
85
+ it 'should not increase after white moves' do
86
+ pos = PGN::FEN.new('4br1k/ppqnr1b1/3p3p/P1pP1p2/2P1pB2/6PP/1P2BP1N/R2QR1K1 w - - 3 25').to_position
87
+ pos.to_fen.fullmove.should
88
+ next_pos = pos.move('Qd2')
89
+ next_pos.to_fen.fullmove.should == '25'
84
90
  end
85
91
 
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"
92
+ it 'should increase after black moves' do
93
+ pos = PGN::FEN.new('4br1k/ppqnr1b1/3p3p/P1pP1p2/2P1pB2/6PP/1P1QBP1N/R3R1K1 b - - 4 25').to_position
94
+ pos.to_fen.fullmove.should
95
+ next_pos = pos.move('Kh7')
96
+ next_pos.to_fen.fullmove.should == '26'
91
97
  end
92
98
  end
93
99
 
94
- describe "displaying FEN notation" do
95
- it "should return a string on inspect" do
100
+ describe 'displaying FEN notation' do
101
+ it 'should return a string on inspect' do
96
102
  fen = PGN::FEN.start
97
103
  fen.inspect.should be_a(String)
98
104
  end
99
105
  end
100
106
 
101
- describe "board_string round-trip" do
107
+ describe 'board_string round-trip' do
102
108
  fens = [
103
109
  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",
110
+ 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6',
111
+ '8/8/8/8/8/8/8/8 w - - 0 1',
112
+ 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1',
113
+ '4k3/4P3/8/8/8/8/8/4K3 w - - 0 1',
114
+ 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
109
115
  ]
110
116
 
111
- it "round-trips every fixture through FEN#to_s" do
117
+ it 'round-trips every fixture through FEN#to_s' do
112
118
  fens.each do |fen|
113
119
  expect(PGN::FEN.new(fen).to_s).to eq(fen), fen
114
120
  end
115
121
  end
116
122
 
117
- it "round-trips the board portion through FEN#to_position.to_fen" do
123
+ it 'round-trips the board portion through FEN#to_position.to_fen' do
118
124
  fens.each do |fen|
119
125
  original = PGN::FEN.new(fen)
120
126
  roundtrip = original.to_position.to_fen
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PGN::Game, 'mutable history' do
6
+ skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
7
+
8
+ let(:game) { PGN::Game.new(%w[e4 e5]) }
9
+
10
+ it 'appends a move with #push and invalidates the position cache' do
11
+ expect(game.positions.length).to eq(3) # start, e4, e5
12
+ game.push('Nf3')
13
+ expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nf3])
14
+ expect(game.positions.length).to eq(4)
15
+ expect(game.current_position.board.at('f3')).to eq('N')
16
+ end
17
+
18
+ it 'rejects an illegal move with ArgumentError' do
19
+ expect { game.push('e5') }.to raise_error(ArgumentError, /illegal move/)
20
+ expect(game.moves.length).to eq(2) # unchanged
21
+ end
22
+
23
+ it 'removes and returns the last move with #pop' do
24
+ last = game.pop
25
+ expect(last.notation).to eq('e5')
26
+ expect(game.moves.map(&:notation)).to eq(%w[e4])
27
+ expect(game.positions.length).to eq(2)
28
+ end
29
+
30
+ it 'returns nil when popping an empty game' do
31
+ empty = PGN::Game.new([])
32
+ expect(empty.pop).to be_nil
33
+ end
34
+
35
+ it 'normalizes castling on push' do
36
+ game = PGN::Game.new(%w[e4 e5 Nf3 Nc6 Bc4 Bc5 O-O Nf6 d3])
37
+ game.push('0-0')
38
+ expect(game.moves.last.notation).to eq('O-O')
39
+ end
40
+
41
+ it 'lets a freshly pushed game reach a checkmate outcome' do
42
+ game = PGN::Game.new(%w[e4 e5 Bc4 Nc6 Qh5 Nf6])
43
+ game.push('Qxf7#')
44
+ expect(game.outcome).to eq(:checkmate)
45
+ end
46
+ end