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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +34 -3
- data/.github/workflows/native.yml +32 -0
- data/.github/workflows/publish.yml +44 -2
- data/.github/workflows/release-gems.yml +43 -0
- data/.github/workflows/release.yml +52 -5
- data/.gitignore +9 -1
- data/.rubocop.yml +46 -6
- data/CHANGELOG.md +215 -0
- data/Gemfile +3 -0
- data/NOTICE.md +21 -0
- data/README.md +134 -5
- data/Rakefile +53 -10
- data/TODO.md +44 -62
- data/bench/baseline_moves.txt +38 -4
- data/bench/baseline_parse.txt +4 -4
- data/bench/cross_check.rb +61 -0
- data/bench/legal_moves.rb +38 -0
- data/bench/perft.rb +32 -0
- data/bench/profile_moves.rb +93 -0
- data/docs/superpowers/plans/2026-08-13-attack-masks-plan.md +51 -0
- data/docs/superpowers/plans/2026-08-13-perf-internals-plan.md +883 -0
- data/docs/superpowers/plans/2026-08-13-rust-bitboard-perft-plan.md +2442 -0
- data/docs/superpowers/plans/2026-08-14-chessie-migration.md +722 -0
- data/docs/superpowers/plans/2026-08-14-rust-integration-plan.md +444 -0
- data/docs/superpowers/plans/2026-08-15-game-tree-api-plan.md +1014 -0
- data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -0
- data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
- data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
- data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
- data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
- data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
- data/ext/pgn2_native/Cargo.lock +321 -0
- data/ext/pgn2_native/Cargo.toml +19 -0
- data/ext/pgn2_native/extconf.rb +8 -0
- data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
- data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
- data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
- data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
- data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
- data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
- data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
- data/lib/pgn/attack.rb +97 -0
- data/lib/pgn/bitboard.rb +13 -0
- data/lib/pgn/board.rb +64 -0
- data/lib/pgn/epd.rb +81 -0
- data/lib/pgn/fen.rb +42 -46
- data/lib/pgn/game.rb +104 -16
- data/lib/pgn/move.rb +20 -16
- data/lib/pgn/move_calculator.rb +13 -1
- data/lib/pgn/node.rb +372 -0
- data/lib/pgn/notation.rb +26 -89
- data/lib/pgn/pgn_parser.rb +30 -31
- data/lib/pgn/pgn_parser.y +14 -15
- data/lib/pgn/position.rb +251 -19
- data/lib/pgn/serializer.rb +13 -18
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn/zobrist.rb +53 -0
- data/lib/pgn.rb +5 -0
- data/pgn2.gemspec +17 -10
- data/spec/bitboard_spec.rb +54 -0
- data/spec/board_spec.rb +53 -0
- data/spec/castling_normalization_spec.rb +39 -0
- data/spec/comment_round_trip_spec.rb +35 -0
- data/spec/epd_spec.rb +44 -0
- data/spec/fen_spec.rb +71 -65
- data/spec/game_history_spec.rb +46 -0
- data/spec/game_spec.rb +112 -15
- data/spec/lexer_spec.rb +5 -5
- data/spec/movetext_clean_spec.rb +44 -0
- data/spec/node_spec.rb +240 -0
- data/spec/notation_spec.rb +5 -0
- data/spec/outcome_spec.rb +93 -0
- data/spec/parser_left_recursion_spec.rb +37 -0
- data/spec/parser_spec.rb +8 -1
- data/spec/position_attack_spec.rb +56 -0
- data/spec/position_legal_spec.rb +123 -0
- data/spec/position_spec.rb +128 -27
- data/spec/serializer_spec.rb +4 -4
- data/spec/zobrist_spec.rb +46 -0
- 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
|
data/spec/position_spec.rb
CHANGED
|
@@ -1,53 +1,50 @@
|
|
|
1
1
|
require 'spec_helper'
|
|
2
2
|
|
|
3
3
|
describe PGN::Position do
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
15
|
-
describe
|
|
16
|
-
pos = PGN::FEN.new(
|
|
17
|
-
next_pos = pos.move(
|
|
18
|
-
|
|
19
|
-
it
|
|
20
|
-
next_pos.board.at(
|
|
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
|
|
24
|
-
next_pos.board.at(
|
|
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
|
|
29
|
-
pos = PGN::FEN.new(
|
|
30
|
-
next_pos = pos.move(
|
|
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(
|
|
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(
|
|
34
|
+
next_pos.board.at('c3').should == 'N'
|
|
38
35
|
end
|
|
39
36
|
end
|
|
40
37
|
|
|
41
|
-
describe
|
|
42
|
-
pos = PGN::FEN.new(
|
|
43
|
-
next_pos = pos.move(
|
|
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
|
|
46
|
-
next_pos.board.at(
|
|
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
|
|
50
|
-
next_pos.board.at(
|
|
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
|
data/spec/serializer_spec.rb
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|