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/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
data/spec/lexer_spec.rb CHANGED
@@ -17,7 +17,7 @@ RSpec.describe PGN::Lexer do
17
17
 
18
18
  it 'discards % rest-of-line comments' do
19
19
  toks = lex("% a comment line\n[Event \"X\"]")
20
- expect(toks.map(&:type)).to eq([:lbracket, :tag_name, :string, :rbracket])
20
+ expect(toks.map(&:type)).to eq(%i[lbracket tag_name string rbracket])
21
21
  end
22
22
 
23
23
  it 'tokenizes the four single-character literals' do
@@ -57,8 +57,8 @@ RSpec.describe PGN::Lexer do
57
57
  end
58
58
 
59
59
  it 'tokenizes nested brace comments recursively' do
60
- toks = lex("{outer {inner} tail}")
61
- expect(toks[0].value).to eq("{outer {inner} tail}")
60
+ toks = lex('{outer {inner} tail}')
61
+ expect(toks[0].value).to eq('{outer {inner} tail}')
62
62
  end
63
63
 
64
64
  it 'tokenizes all game terminations' do
@@ -116,8 +116,8 @@ RSpec.describe PGN::Lexer do
116
116
  end
117
117
 
118
118
  it 'records byte offsets on every token' do
119
- toks = lex("1. e4 e5 1-0")
120
- expect(toks[0].offset).to eq(0) # "1."
119
+ toks = lex('1. e4 e5 1-0')
120
+ expect(toks[0].offset).to eq(0) # "1."
121
121
  expect(toks[1].offset).to eq(3) # "e4"
122
122
  expect(toks[2].offset).to eq(6) # "e5"
123
123
  expect(toks[3].offset).to eq(9) # "1-0"
@@ -22,6 +22,11 @@ describe PGN::Notation do
22
22
  'g1', 'f3')).to eq('Nf3')
23
23
  end
24
24
 
25
+ it 'generates a non-castling king walk' do
26
+ fen = '4k3/8/8/8/8/8/8/4K3 w - - 0 1'
27
+ expect(PGN::Notation.san(pos(fen), 'e1', 'e2')).to eq('Ke2')
28
+ end
29
+
25
30
  it 'generates a pawn capture with the origin file' do
26
31
  fen = 'rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 2'
27
32
  expect(PGN::Notation.san(pos(fen), 'e4', 'd5')).to eq('exd5')
data/spec/parser_spec.rb CHANGED
@@ -107,7 +107,14 @@ describe PGN do
107
107
  it 'returns original game pgn' do
108
108
  games = PGN.parse(File.read('./spec/pgn_files/fen.pgn'))
109
109
  game = games.first
110
- expect(game.pgn).to eq "[Event \"Event 1\"]\n[FEN \"4brkn/4bp1p/3q2pP/8/2B3N1/1P4N1/2PP3P/1K2Q3 w - - 0 1\"]\n[PlyCount \"7\"]\n\n1. Qxe7 Qxe7 2. Ne4 Bc6 3. Nef6+ Qxf6 4. Nxf6# 1-0"
110
+ expected = <<~PGN.chomp
111
+ [Event "Event 1"]
112
+ [FEN "4brkn/4bp1p/3q2pP/8/2B3N1/1P4N1/2PP3P/1K2Q3 w - - 0 1"]
113
+ [PlyCount "7"]
114
+
115
+ 1. Qxe7 Qxe7 2. Ne4 Bc6 3. Nef6+ Qxf6 4. Nxf6# 1-0
116
+ PGN
117
+ expect(game.pgn).to eq expected
111
118
  end
112
119
 
113
120
  it 'returns original game pgn for second game' do
@@ -1,53 +1,50 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe PGN::Position do
4
-
5
- describe "start" do
6
-
7
- it "should have fullmove 1" do
4
+ describe 'start' do
5
+ it 'should have fullmove 1' do
8
6
  pos = PGN::Position.start
9
7
  pos.fullmove.should == 1
10
8
  end
11
-
12
9
  end
13
-
14
- context "disambiguating moves" do
15
- describe "using SAN square disambiguation" do
16
- pos = PGN::FEN.new("r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6").to_position
17
- next_pos = pos.move("Ndb5")
18
-
19
- it "should move the specified piece" do
20
- next_pos.board.at("d4").should be_nil
10
+
11
+ context 'disambiguating moves' do
12
+ describe 'using SAN square disambiguation' do
13
+ pos = PGN::FEN.new('r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6').to_position
14
+ next_pos = pos.move('Ndb5')
15
+
16
+ it 'should move the specified piece' do
17
+ next_pos.board.at('d4').should be_nil
21
18
  end
22
19
 
23
- it "should not move the other piece" do
24
- next_pos.board.at("c3").should == "N"
20
+ it 'should not move the other piece' do
21
+ next_pos.board.at('c3').should == 'N'
25
22
  end
26
23
  end
27
24
 
28
- describe "using discovered check" do
29
- pos = PGN::FEN.new("rnbqk2r/p1pp1ppp/1p2pn2/8/1bPP4/2N1P3/PP3PPP/R1BQKBNR w KQkq - 0 5").to_position
30
- next_pos = pos.move("Ne2")
25
+ describe 'using discovered check' do
26
+ pos = PGN::FEN.new('rnbqk2r/p1pp1ppp/1p2pn2/8/1bPP4/2N1P3/PP3PPP/R1BQKBNR w KQkq - 0 5').to_position
27
+ next_pos = pos.move('Ne2')
31
28
 
32
29
  it "should move the piece that doesn't give discovered check" do
33
- next_pos.board.at("g1").should be_nil
30
+ next_pos.board.at('g1').should be_nil
34
31
  end
35
32
 
36
33
  it "shouldn't move the other piece" do
37
- next_pos.board.at("c3").should == "N"
34
+ next_pos.board.at('c3').should == 'N'
38
35
  end
39
36
  end
40
37
 
41
- describe "with two pawns on the same file" do
42
- pos = PGN::FEN.new("r2q1rk1/4bppp/p3n3/1p2n3/4N3/1B2BP2/PP3P1P/R2Q1RK1 w - - 4 19").to_position
43
- next_pos = pos.move("f4")
38
+ describe 'with two pawns on the same file' do
39
+ pos = PGN::FEN.new('r2q1rk1/4bppp/p3n3/1p2n3/4N3/1B2BP2/PP3P1P/R2Q1RK1 w - - 4 19').to_position
40
+ next_pos = pos.move('f4')
44
41
 
45
- it "should move the pawn in front" do
46
- next_pos.board.at("f3").should be_nil
42
+ it 'should move the pawn in front' do
43
+ next_pos.board.at('f3').should be_nil
47
44
  end
48
45
 
49
- it "should not move the other pawn" do
50
- next_pos.board.at("f2").should == "P"
46
+ it 'should not move the other pawn' do
47
+ next_pos.board.at('f2').should == 'P'
51
48
  end
52
49
  end
53
50
  end
@@ -124,4 +121,108 @@ describe PGN::Position do
124
121
  expect(parsed.to_fen.to_s).to eq(fen)
125
122
  end
126
123
  end
124
+
125
+ describe '#zobrist and equality' do
126
+ it 'seeds the start position with a stable hash' do
127
+ expect(PGN::Position.start.zobrist).to be_an(Integer)
128
+ expect(PGN::Position.start.zobrist).to eq(PGN::Position.start.zobrist)
129
+ end
130
+
131
+ it 'updates the hash after a move (and matches a fresh seed)' do
132
+ start = PGN::Position.start
133
+ after = start.move('e4')
134
+ expect(after.zobrist).to eq(
135
+ PGN::Zobrist.seed(after.board, after.player, after.castling, after.en_passant)
136
+ )
137
+ end
138
+
139
+ it 'is equal to an independently built equivalent position' do
140
+ a = PGN::Position.start.move('e4').move('e5')
141
+ b = PGN::Position.start.move('e4').move('e5')
142
+ expect(a).to eq(b)
143
+ expect(a.hash).to eq(b.hash)
144
+ end
145
+
146
+ it 'is not equal when only the side to move differs' do
147
+ a = PGN::Position.start
148
+ b = PGN::Position.start.move('e4')
149
+ expect(a).not_to eq(b)
150
+ end
151
+
152
+ it 'is not equal when castling rights differ' do
153
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
154
+ a = PGN::FEN.new(fen).to_position
155
+ b = a.move('O-O')
156
+ expect(a).not_to eq(b)
157
+ end
158
+
159
+ it 'ignores halfmove and fullmove counters for equality' do
160
+ a = PGN::Position.new(PGN::Board.start, :white, %w[K Q k q], nil, 0, 1)
161
+ b = PGN::Position.new(PGN::Board.start, :white, %w[K Q k q], nil, 7, 9)
162
+ expect(a).to eq(b)
163
+ expect(a.hash).to eq(b.hash)
164
+ end
165
+
166
+ it 'keeps the replay hash in sync with a fresh seed for a whole game' do
167
+ moves = %w[e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 a6 Be2 e6]
168
+ pos = PGN::Position.start
169
+ moves.each do |m|
170
+ pos = pos.move(m)
171
+ expect(pos.zobrist).to eq(
172
+ PGN::Zobrist.seed(pos.board, pos.player, pos.castling, pos.en_passant)
173
+ ), "drift after #{m}"
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ RSpec.describe PGN::Position, '#perft' do
180
+ it 'returns 1 at depth 0 for the start position' do
181
+ expect(PGN::Position.start.perft(0)).to eq(1)
182
+ end
183
+
184
+ it 'matches published startpos perft values' do
185
+ p = PGN::Position.start
186
+ expect(p.perft(1)).to eq(20)
187
+ expect(p.perft(2)).to eq(400)
188
+ expect(p.perft(3)).to eq(8_902)
189
+ expect(p.perft(4)).to eq(197_281)
190
+ end
191
+
192
+ it 'matches published Kiwipete perft values' do
193
+ fen = 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1'
194
+ p = PGN::FEN.new(fen).to_position
195
+ expect(p.perft(1)).to eq(48)
196
+ expect(p.perft(2)).to eq(2_039)
197
+ expect(p.perft(3)).to eq(97_862)
198
+ end
199
+
200
+ it 'raises ArgumentError on negative depth' do
201
+ expect { PGN::Position.start.perft(-1) }.to raise_error(ArgumentError)
202
+ end
203
+ end
204
+
205
+ RSpec.describe PGN::Position, '#legal_moves' do
206
+ it 'lists 20 legal moves from the start position' do
207
+ expect(PGN::Position.start.legal_moves.length).to eq(20)
208
+ end
209
+
210
+ it 'returns sorted UCI strings matching the UCI format' do
211
+ moves = PGN::Position.start.legal_moves
212
+ expect(moves).to eq(moves.sort)
213
+ expect(moves).to all(match(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/))
214
+ end
215
+
216
+ it 'matches the engine direct output for the same FEN (delegation equivalence)' do
217
+ fen = 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1'
218
+ pos = PGN::FEN.new(fen).to_position
219
+ expect(pos.legal_moves).to eq(PGN::Bitboard::Engine.new(fen).legal_moves)
220
+ end
221
+
222
+ it 'includes a promotion UCI when one is legal' do
223
+ # White king e1, black king h1, white pawn e7 — e7e8q must be legal.
224
+ fen = '8/4P3/8/8/8/8/8/4K2k w - - 0 1'
225
+ pos = PGN::FEN.new(fen).to_position
226
+ expect(pos.legal_moves).to include('e7e8q')
227
+ end
127
228
  end
@@ -62,10 +62,10 @@ describe PGN::Serializer do
62
62
  it 'reproduces the variations.pgn movetext shape, including 2... after variations' do
63
63
  game = PGN.parse(File.read('./spec/pgn_files/variations.pgn')).first
64
64
  expected = "[Black \"Petrov\"]\n[White \"Somebody\"]\n\n" \
65
- "1. e4 e5 2. Nf3 { comment } " \
66
- "(2. f4 exf4 { final variation }) " \
67
- "(2. Nc3 { other } 2... d5 (2... f5) 3. exd5) " \
68
- "2... Nf6 *\n"
65
+ '1. e4 e5 2. Nf3 { comment } ' \
66
+ '(2. f4 exf4 { final variation }) ' \
67
+ '(2. Nc3 { other } 2... d5 (2... f5) 3. exd5) ' \
68
+ "2... Nf6 *\n"
69
69
  expect(PGN::Serializer.new(game).to_s).to eq(expected)
70
70
  end
71
71
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe PGN::Zobrist do
6
+ it 'exposes table, side, castling, and ep_file constants' do
7
+ expect(PGN::Zobrist::TABLE).to be_a(Hash)
8
+ expect(PGN::Zobrist::TABLE.keys.sort).to eq(%w[B K N P Q R b k n p q r].sort)
9
+ expect(PGN::Zobrist::TABLE['P'].length).to eq(128)
10
+ expect(PGN::Zobrist::SIDE).to be_an(Integer)
11
+ expect(PGN::Zobrist::CASTLING).to be_a(Hash)
12
+ expect(PGN::Zobrist::CASTLING.keys.sort).to eq(%w[K Q k q].sort)
13
+ expect(PGN::Zobrist::EP_FILE.length).to eq(8)
14
+ end
15
+
16
+ it 'seeds the starting position to a stable integer' do
17
+ first = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], nil)
18
+ second = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], nil)
19
+ expect(first).to eq(second)
20
+ expect(first).to be_an(Integer)
21
+ end
22
+
23
+ it 'differs when the side to move changes' do
24
+ white_to_move = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], nil)
25
+ black_to_move = PGN::Zobrist.seed(PGN::Board.start, :black, %w[K Q k q], nil)
26
+ expect(white_to_move).not_to eq(black_to_move)
27
+ end
28
+
29
+ it 'differs when castling rights change' do
30
+ full = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], nil)
31
+ no_k = PGN::Zobrist.seed(PGN::Board.start, :white, %w[Q k q], nil)
32
+ expect(full).not_to eq(no_k)
33
+ end
34
+
35
+ it 'differs when an en-passant file is present vs absent' do
36
+ with_ep = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], 'e3')
37
+ no_ep = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], nil)
38
+ expect(with_ep).not_to eq(no_ep)
39
+ end
40
+
41
+ it 'is deterministic across processes (frozen constants)' do
42
+ expect(PGN::Zobrist::TABLE).to be_frozen
43
+ expect(PGN::Zobrist::CASTLING).to be_frozen
44
+ expect(PGN::Zobrist::EP_FILE).to be_frozen
45
+ end
46
+ end