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
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PGN::Position, 'attack helpers' do
6
+ describe '#in_check?' do
7
+ it 'is false for the start position' do
8
+ expect(PGN::Position.start.in_check?).to be(false)
9
+ end
10
+
11
+ it 'is true when the side to move is checked' do
12
+ # White king on e1, black rook on e2 giving check.
13
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/4r3/4K3 w - - 0 1').to_position
14
+ expect(pos.in_check?).to be(true)
15
+ end
16
+
17
+ it 'is false when a piece blocks the check' do
18
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/4p3/4R1K1 w - - 0 1').to_position
19
+ expect(pos.in_check?).to be(false)
20
+ end
21
+
22
+ it 'is false for black to move when black is not in check' do
23
+ pos = PGN::FEN.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1').to_position
24
+ expect(pos.in_check?).to be(false)
25
+ end
26
+ end
27
+
28
+ describe '#attackers' do
29
+ it 'lists the attacking squares for the opponent color' do
30
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/4r3/4K3 w - - 0 1').to_position
31
+ # White king on e1 is attacked by the black rook on e2.
32
+ expect(pos.attackers('e1')).to include('e2')
33
+ end
34
+
35
+ it 'lists pawn attackers' do
36
+ # White pawn d5 attacks e6 and c6; ask for attackers of e6 by white.
37
+ pos = PGN::FEN.new('4k3/8/8/3P4/8/8/8/4K3 w - - 0 1').to_position
38
+ expect(pos.attackers('e6', 'w')).to include('d5')
39
+ end
40
+
41
+ it 'lists knight attackers' do
42
+ pos = PGN::FEN.new('4k3/8/8/8/8/2N5/8/4K3 w - - 0 1').to_position
43
+ expect(pos.attackers('e4', 'w')).to include('c3')
44
+ end
45
+
46
+ it 'lists bishop/rook/queen attackers' do
47
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/8/Q3K3 w - - 0 1').to_position
48
+ expect(pos.attackers('a8', 'w')).to include('a1')
49
+ end
50
+
51
+ it 'returns an empty array when nothing attacks the square' do
52
+ pos = PGN::Position.start
53
+ expect(pos.attackers('e4')).to eq([])
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PGN::Position, '#legal?' do
6
+ skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
7
+
8
+ let(:start) { PGN::Position.start }
9
+
10
+ it 'accepts SAN pawn moves' do
11
+ expect(start.legal?('e4')).to be(true)
12
+ expect(start.legal?('d4')).to be(true)
13
+ expect(start.legal?('e5')).to be(false) # no pawn on e5 can move
14
+ end
15
+
16
+ it 'accepts SAN piece moves' do
17
+ expect(start.legal?('Nf3')).to be(true)
18
+ expect(start.legal?('Nc3')).to be(true)
19
+ expect(start.legal?('Nb1')).to be(false) # no piece can move to b1 from start
20
+ end
21
+
22
+ it 'accepts UCI strings' do
23
+ expect(start.legal?('e2e4')).to be(true)
24
+ expect(start.legal?('e2e5')).to be(false)
25
+ expect(start.legal?('g1f3')).to be(true)
26
+ expect(start.legal?('b1a3')).to be(true) # knight on b1 can reach a3
27
+ expect(start.legal?('b1b3')).to be(false) # knights do not move two squares
28
+ end
29
+
30
+ it 'accepts SAN with check/mate suffixes' do
31
+ # Scholar's mate position before the queen move: 1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6?? 4. Qxf7#
32
+ pos = PGN::FEN.new('r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR w KQkq - 4 4').to_position
33
+ expect(pos.legal?('Qxf7#')).to be(true)
34
+ expect(pos.legal?('Qxf7+')).to be(true)
35
+ expect(pos.legal?('Qxf7')).to be(true)
36
+ end
37
+
38
+ it 'accepts redundant disambiguation in SAN' do
39
+ # Two knights (b1 and f1) can reach d2; "Nbd2" and "Nfd2" are both legal,
40
+ # and the unambiguous "Nd2" is rejected as ambiguous.
41
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/8/1N3NK1 w - - 0 1').to_position
42
+ expect(pos.legal?('Nbd2')).to be(true)
43
+ expect(pos.legal?('Nfd2')).to be(true)
44
+ expect(pos.legal?('Nd2')).to be(false) # ambiguous, no disambiguation
45
+ end
46
+
47
+ it 'handles castling SAN and UCI' do
48
+ pos = PGN::FEN.new('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').to_position
49
+ expect(pos.legal?('O-O')).to be(true)
50
+ expect(pos.legal?('O-O-O')).to be(true)
51
+ expect(pos.legal?('e1g1')).to be(true)
52
+ expect(pos.legal?('e1c1')).to be(true)
53
+ end
54
+
55
+ it 'handles promotion SAN and UCI' do
56
+ pos = PGN::FEN.new('8/P7/8/8/8/8/8/4k2K w - - 0 1').to_position
57
+ expect(pos.legal?('a8=Q')).to be(true)
58
+ expect(pos.legal?('a8=N')).to be(true)
59
+ expect(pos.legal?('a7a8q')).to be(true)
60
+ expect(pos.legal?('a7a8n')).to be(true)
61
+ end
62
+
63
+ it 'handles en passant SAN' do
64
+ pos = PGN::FEN.new('rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3').to_position
65
+ expect(pos.legal?('exd6')).to be(true)
66
+ expect(pos.legal?('e5d6')).to be(true)
67
+ end
68
+
69
+ it 'rejects moves that leave the king in check' do
70
+ # White king on e1, rook pinning the knight on e2 to the king along the e-file.
71
+ pos = PGN::FEN.new('4r3/8/8/8/8/8/4N3/4K3 w - - 0 1').to_position
72
+ expect(pos.legal?('Nf4')).to be(false) # knight moves away, king is in check from rook
73
+ end
74
+
75
+ it 'returns false for malformed input' do
76
+ expect(start.legal?('z9z9')).to be(false)
77
+ expect(start.legal?('Qxh8')).to be(false) # no legal queen move from start
78
+ expect(start.legal?('')).to be(false)
79
+ end
80
+
81
+ it 'respects the side to move' do
82
+ # Black to move; a white move should be illegal.
83
+ pos = PGN::FEN.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1').to_position
84
+ expect(pos.legal?('e4')).to be(false)
85
+ expect(pos.legal?('e5')).to be(true)
86
+ end
87
+ end
88
+
89
+ RSpec.describe PGN::Position, '#legal_moves_san' do
90
+ skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
91
+
92
+ it 'returns 20 sorted SAN moves for the start position' do
93
+ sans = PGN::Position.start.legal_moves_san
94
+ expect(sans.length).to eq(20)
95
+ expect(sans).to eq(sans.sort)
96
+ expect(sans).to include('e4', 'Nf3', 'd4', 'a3')
97
+ end
98
+
99
+ it 'includes castling and promotion SAN' do
100
+ pos = PGN::FEN.new('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').to_position
101
+ sans = pos.legal_moves_san
102
+ expect(sans).to include('O-O', 'O-O-O')
103
+ end
104
+
105
+ it 'produces SAN whose moves are all legal? and vice versa' do
106
+ pos = PGN::FEN.new('r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR w KQkq - 4 4').to_position
107
+ sans = pos.legal_moves_san
108
+ uci = pos.legal_moves
109
+ expect(sans.length).to eq(uci.length)
110
+ expect(sans.all? { |s| pos.legal?(s) }).to be(true)
111
+ end
112
+
113
+ it 'round-trips: every legal UCI maps to a SAN that legal? accepts' do
114
+ pos = PGN::FEN.new('rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3').to_position
115
+ pos.legal_moves.each do |uci|
116
+ from = uci[0, 2]
117
+ to = uci[2, 2]
118
+ promo = uci[5]
119
+ san = PGN::Notation.san(pos, from, to, promo)
120
+ expect(pos.legal?(san)).to be(true), "#{san} (from #{uci}) should be legal"
121
+ end
122
+ end
123
+ end
@@ -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