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.
- 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 +40 -0
- data/.github/workflows/release.yml +52 -5
- data/.gitignore +9 -1
- data/.rubocop.yml +46 -6
- data/CHANGELOG.md +183 -1
- data/Gemfile +3 -0
- data/NOTICE.md +21 -0
- data/README.md +107 -5
- data/Rakefile +35 -10
- data/TODO.md +102 -31
- 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/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/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/bitboard.rb +13 -0
- data/lib/pgn/board.rb +103 -10
- data/lib/pgn/fen.rb +35 -46
- data/lib/pgn/game.rb +22 -13
- data/lib/pgn/lexer.rb +9 -6
- data/lib/pgn/move.rb +19 -15
- data/lib/pgn/move_calculator.rb +46 -20
- data/lib/pgn/notation.rb +24 -29
- data/lib/pgn/position.rb +60 -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 +2 -0
- data/pgn2.gemspec +17 -10
- data/spec/bitboard_spec.rb +54 -0
- data/spec/board_spec.rb +53 -0
- data/spec/fen_spec.rb +65 -65
- data/spec/game_spec.rb +52 -15
- data/spec/lexer_spec.rb +5 -5
- data/spec/notation_spec.rb +5 -0
- data/spec/parser_spec.rb +8 -1
- data/spec/position_spec.rb +128 -27
- data/spec/serializer_spec.rb +4 -4
- data/spec/zobrist_spec.rb +46 -0
- metadata +101 -36
data/spec/fen_spec.rb
CHANGED
|
@@ -1,120 +1,120 @@
|
|
|
1
|
-
require
|
|
1
|
+
require 'spec_helper'
|
|
2
2
|
|
|
3
3
|
describe PGN::FEN do
|
|
4
|
-
describe
|
|
5
|
-
it
|
|
6
|
-
pos = PGN::FEN.new(
|
|
7
|
-
next_pos = pos.move(
|
|
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
|
|
12
|
-
pos = PGN::FEN.new(
|
|
13
|
-
next_pos = pos.move(
|
|
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
|
|
18
|
-
pos = PGN::FEN.new(
|
|
19
|
-
next_pos = pos.move(
|
|
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
|
|
25
|
-
pos = PGN::FEN.new(
|
|
26
|
-
next_pos = pos.move(
|
|
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
|
|
31
|
-
pos = PGN::FEN.new(
|
|
32
|
-
pos.to_fen.castling.should_not
|
|
33
|
-
next_pos = pos.move(
|
|
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
|
|
39
|
-
it
|
|
40
|
-
pos = PGN::FEN.new(
|
|
41
|
-
next_pos = pos.move(
|
|
42
|
-
next_pos.to_fen.en_passant.should ==
|
|
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
|
|
46
|
-
pos = PGN::FEN.new(
|
|
47
|
-
next_pos = pos.move(
|
|
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
|
|
53
|
-
it
|
|
54
|
-
pos = PGN::FEN.new(
|
|
55
|
-
pos.to_fen.halfmove.should
|
|
56
|
-
next_pos = pos.move(
|
|
57
|
-
next_pos.to_fen.halfmove.should ==
|
|
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
|
|
61
|
-
pos = PGN::FEN.new(
|
|
62
|
-
pos.to_fen.halfmove.should
|
|
63
|
-
next_pos = pos.move(
|
|
64
|
-
next_pos.to_fen.halfmove.should ==
|
|
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
|
|
68
|
-
pos = PGN::FEN.new(
|
|
69
|
-
pos.to_fen.halfmove.should
|
|
70
|
-
moves = %w
|
|
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
|
|
79
|
-
it
|
|
80
|
-
pos = PGN::FEN.new(
|
|
81
|
-
pos.to_fen.fullmove.should
|
|
82
|
-
next_pos = pos.move(
|
|
83
|
-
next_pos.to_fen.fullmove.should ==
|
|
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
|
|
87
|
-
pos = PGN::FEN.new(
|
|
88
|
-
pos.to_fen.fullmove.should
|
|
89
|
-
next_pos = pos.move(
|
|
90
|
-
next_pos.to_fen.fullmove.should ==
|
|
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
|
|
95
|
-
it
|
|
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
|
|
101
|
+
describe 'board_string round-trip' do
|
|
102
102
|
fens = [
|
|
103
103
|
PGN::FEN::INITIAL,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
38
|
-
c =
|
|
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 =
|
|
44
|
-
vars = (
|
|
45
|
-
"#{
|
|
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
|
-
|
|
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
|
-
|
|
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 { |
|
|
62
|
-
act_sigs = (actual[i].variations || []).map { |v| v.map { |
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
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([
|
|
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(
|
|
61
|
-
expect(toks[0].value).to eq(
|
|
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(
|
|
120
|
-
expect(toks[0].offset).to eq(0)
|
|
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"
|
data/spec/notation_spec.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
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
|