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
data/pgn2.gemspec
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require 'English'
|
|
1
2
|
lib = File.expand_path('lib', __dir__)
|
|
2
3
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
3
4
|
require 'pgn/version'
|
|
@@ -10,27 +11,33 @@ Gem::Specification.new do |spec|
|
|
|
10
11
|
spec.description = 'A PGN parser and FEN generator for Ruby'
|
|
11
12
|
spec.summary = 'A PGN parser for Ruby'
|
|
12
13
|
spec.homepage = 'https://github.com/muriloime/pgn'
|
|
13
|
-
spec.
|
|
14
|
+
spec.licenses = ['MIT', 'MPL-2.0']
|
|
14
15
|
spec.required_ruby_version = '>= 3.0'
|
|
15
16
|
|
|
16
17
|
spec.metadata = {
|
|
17
|
-
'homepage_uri'
|
|
18
|
-
'source_code_uri'
|
|
19
|
-
'bug_tracker_uri'
|
|
20
|
-
'changelog_uri'
|
|
18
|
+
'homepage_uri' => 'https://github.com/muriloime/pgn',
|
|
19
|
+
'source_code_uri' => 'https://github.com/muriloime/pgn',
|
|
20
|
+
'bug_tracker_uri' => 'https://github.com/muriloime/pgn/issues',
|
|
21
|
+
'changelog_uri' => 'https://github.com/muriloime/pgn/blob/main/CHANGELOG.md',
|
|
22
|
+
'rubygems_mfa_required' => 'true'
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
spec.files = `git ls-files`.split(
|
|
25
|
+
spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
|
|
24
26
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
|
25
|
-
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
|
26
27
|
spec.require_paths = ['lib']
|
|
27
28
|
|
|
29
|
+
spec.extensions = ['ext/pgn2_native/extconf.rb']
|
|
30
|
+
spec.add_dependency 'rb_sys', '~> 0.9.39'
|
|
31
|
+
spec.add_development_dependency 'rake-compiler', '~> 1.2'
|
|
32
|
+
spec.add_development_dependency 'rake-compiler-dock', '~> 1.6'
|
|
33
|
+
|
|
34
|
+
spec.add_development_dependency 'benchmark-ips'
|
|
28
35
|
spec.add_development_dependency 'bundler', '~> 2.3'
|
|
36
|
+
spec.add_development_dependency 'bundler-audit'
|
|
37
|
+
spec.add_development_dependency 'memory_profiler'
|
|
29
38
|
spec.add_development_dependency 'pry'
|
|
39
|
+
spec.add_development_dependency 'racc'
|
|
30
40
|
spec.add_development_dependency 'rake'
|
|
31
41
|
spec.add_development_dependency 'rspec'
|
|
32
|
-
spec.add_development_dependency 'racc'
|
|
33
|
-
spec.add_development_dependency 'benchmark-ips'
|
|
34
|
-
spec.add_development_dependency 'memory_profiler'
|
|
35
42
|
spec.add_development_dependency 'rubocop', '~> 1.60'
|
|
36
43
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe PGN::Bitboard::Engine do
|
|
4
|
+
let(:startpos) { 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' }
|
|
5
|
+
|
|
6
|
+
# The native extension is optional from the gem's perspective; skip the
|
|
7
|
+
# whole suite if it isn't compiled in this environment.
|
|
8
|
+
skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
|
|
9
|
+
|
|
10
|
+
it 'perfts the start position' do
|
|
11
|
+
e = described_class.new(startpos)
|
|
12
|
+
expect(e.perft(1)).to eq(20)
|
|
13
|
+
expect(e.perft(2)).to eq(400)
|
|
14
|
+
expect(e.perft(3)).to eq(8902)
|
|
15
|
+
expect(e.perft(4)).to eq(197_281)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'perfts Kiwipete' do
|
|
19
|
+
e = described_class.new('r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1')
|
|
20
|
+
expect(e.perft(1)).to eq(48)
|
|
21
|
+
expect(e.perft(3)).to eq(97_862)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'raises on a bad FEN' do
|
|
25
|
+
expect { described_class.new('not a fen') }.to raise_error(ArgumentError)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'lists legal moves in sorted UCI' do
|
|
29
|
+
e = described_class.new(startpos)
|
|
30
|
+
moves = e.legal_moves
|
|
31
|
+
expect(moves.length).to eq(20)
|
|
32
|
+
expect(moves).to eq(moves.sort)
|
|
33
|
+
expect(moves).to include('e2e4', 'g1f3', 'd2d4')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'answers legal? with UCI' do
|
|
37
|
+
e = described_class.new(startpos)
|
|
38
|
+
expect(e.legal?('e2e4')).to be(true)
|
|
39
|
+
expect(e.legal?('e2e5')).to be(false)
|
|
40
|
+
expect(e.legal?('g1f3')).to be(true)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'encodes promotions in legal_moves' do
|
|
44
|
+
e = described_class.new('8/P7/8/8/8/8/8/4k2K w - - 0 1')
|
|
45
|
+
moves = e.legal_moves
|
|
46
|
+
expect(moves).to include('a7a8q', 'a7a8r', 'a7a8b', 'a7a8n')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'recognizes castling as legal' do
|
|
50
|
+
e = described_class.new('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1')
|
|
51
|
+
expect(e.legal?('e1g1')).to be(true)
|
|
52
|
+
expect(e.legal?('e1c1')).to be(true)
|
|
53
|
+
end
|
|
54
|
+
end
|
data/spec/board_spec.rb
CHANGED
|
@@ -7,6 +7,36 @@ describe PGN::Board do
|
|
|
7
7
|
end
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
describe 'attack masks' do
|
|
11
|
+
it 'KNIGHT_ATTACKS lists the on-board knight targets for each 0x88 index' do
|
|
12
|
+
expect(PGN::Board::KNIGHT_ATTACKS[0]).to contain_exactly(18, 33) # a1: c3(0x12), b3(0x21)
|
|
13
|
+
expect(PGN::Board::KNIGHT_ATTACKS[0x54].length).to eq(8) # e4 = 0x54, centre
|
|
14
|
+
expect(PGN::Board::KNIGHT_ATTACKS[0x77]).to contain_exactly(86, 101) # h8: f7(0x56), g6(0x65)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'KING_ATTACKS lists the on-board king targets for each 0x88 index' do
|
|
18
|
+
expect(PGN::Board::KING_ATTACKS[0]).to contain_exactly(1, 16, 17) # a1: b1, a2, b2
|
|
19
|
+
expect(PGN::Board::KING_ATTACKS[0x54].length).to eq(8)
|
|
20
|
+
expect(PGN::Board::KING_ATTACKS[0x77]).to contain_exactly(102, 103, 118) # h8: g7, h7, g8
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'only contains on-board squares and is nil for off-board source indices' do
|
|
24
|
+
on_board = (0...128).select { |i| i.nobits?(0x88) }
|
|
25
|
+
expect(PGN::Board::KNIGHT_ATTACKS.length).to eq(128)
|
|
26
|
+
expect(PGN::Board::KING_ATTACKS.length).to eq(128)
|
|
27
|
+
on_board.each do |i|
|
|
28
|
+
expect(PGN::Board::KNIGHT_ATTACKS[i]).to be_an(Array)
|
|
29
|
+
expect(PGN::Board::KING_ATTACKS[i]).to be_an(Array)
|
|
30
|
+
expect(PGN::Board::KNIGHT_ATTACKS[i]).to all(satisfy { |sq| sq.nobits?(0x88) })
|
|
31
|
+
expect(PGN::Board::KING_ATTACKS[i]).to all(satisfy { |sq| sq.nobits?(0x88) })
|
|
32
|
+
end
|
|
33
|
+
(0...128).reject { |i| i.nobits?(0x88) }.each do |i|
|
|
34
|
+
expect(PGN::Board::KNIGHT_ATTACKS[i]).to be_nil
|
|
35
|
+
expect(PGN::Board::KING_ATTACKS[i]).to be_nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
10
40
|
describe '#at' do
|
|
11
41
|
# Use .dup because PGN::Board.start returns the same shared board
|
|
12
42
|
# backed by the frozen START constant.
|
|
@@ -100,6 +130,29 @@ describe PGN::Board do
|
|
|
100
130
|
end
|
|
101
131
|
end
|
|
102
132
|
|
|
133
|
+
describe '#fen_board_string' do
|
|
134
|
+
it 'serializes the start position to the FEN board string' do
|
|
135
|
+
expect(PGN::Board.start.fen_board_string)
|
|
136
|
+
.to eq('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR')
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'collapses runs of empty squares into digits' do
|
|
140
|
+
board = PGN::FEN.new('8/8/8/8/8/8/8/8 w - - 0 1').board
|
|
141
|
+
expect(board.fen_board_string).to eq('8/8/8/8/8/8/8/8')
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'serializes a mid-game board byte-for-byte like FEN#board_string' do
|
|
145
|
+
fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
|
|
146
|
+
board = PGN::FEN.new(fen).board
|
|
147
|
+
expect(board.fen_board_string).to eq(fen.split.first)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'serializes rank 8 first and rank 1 last (FEN order)' do
|
|
151
|
+
board = PGN::FEN.new('4k3/8/8/8/8/8/8/4K3 w - - 0 1').board
|
|
152
|
+
expect(board.fen_board_string).to eq('4k3/8/8/8/8/8/8/4K3')
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
103
156
|
describe '#inspect' do
|
|
104
157
|
it 'returns a string with unicode pieces and newlines' do
|
|
105
158
|
inspected = PGN::Board.start.inspect
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe 'UCI-style castling normalization' do
|
|
6
|
+
let(:fen) { 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1' }
|
|
7
|
+
let(:pos) { PGN::FEN.new(fen).to_position }
|
|
8
|
+
|
|
9
|
+
it 'parses 0-0 / 0-0-0 the same as O-O / O-O-O in PGN::Move' do
|
|
10
|
+
expect(PGN::Move.new('0-0', :white).castle).to eq('K')
|
|
11
|
+
expect(PGN::Move.new('0-0-0', :white).castle).to eq('Q')
|
|
12
|
+
expect(PGN::Move.new('0-0', :black).castle).to eq('k')
|
|
13
|
+
expect(PGN::Move.new('0-0-0', :black).castle).to eq('q')
|
|
14
|
+
expect(PGN::Move.new('0-0', :white).piece).to be_nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'normalizes parsed 0-0 movetext to O-O on a game' do
|
|
18
|
+
game = PGN::Game.new(%w[0-0])
|
|
19
|
+
expect(game.moves.first.notation).to eq('O-O')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'Notation.san produces O-O / O-O-O for the king two-file moves' do
|
|
23
|
+
expect(PGN::Notation.san(pos, 'e1', 'g1')).to eq('O-O')
|
|
24
|
+
expect(PGN::Notation.san(pos, 'e1', 'c1')).to eq('O-O-O')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'Position#legal? accepts both O-O and UCI e1g1 castling' do
|
|
28
|
+
expect(pos.legal?('O-O')).to be(true)
|
|
29
|
+
expect(pos.legal?('O-O-O')).to be(true)
|
|
30
|
+
expect(pos.legal?('e1g1')).to be(true)
|
|
31
|
+
expect(pos.legal?('e1c1')).to be(true)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'serializes castling as O-O, never 0-0' do
|
|
35
|
+
game = PGN::Game.new(%w[e4 e5 O-O])
|
|
36
|
+
expect(game.to_pgn).to include('O-O')
|
|
37
|
+
expect(game.to_pgn).not_to include('0-0')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe 'comment round-tripping' do
|
|
6
|
+
it 'round-trips nested braces through parse -> serialize -> parse' do
|
|
7
|
+
input = +"[White \"A\"]\n\n1. e4 {this {is a} nested {comment}} *\n"
|
|
8
|
+
g1 = PGN.parse(input, Encoding::UTF_8).first
|
|
9
|
+
g2 = PGN.parse(g1.to_pgn, Encoding::UTF_8).first
|
|
10
|
+
expect(g2.moves.first.comment).to eq(g1.moves.first.comment)
|
|
11
|
+
expect(g2.moves.first.comment).to eq('this {is a} nested {comment}')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'is stable: serialize -> parse -> serialize is idempotent' do
|
|
15
|
+
input = +"[White \"A\"]\n\n1. e4 {this {is a} nested {comment}} *\n"
|
|
16
|
+
once = PGN.parse(input, Encoding::UTF_8).first.to_pgn
|
|
17
|
+
twice = PGN.parse(once, Encoding::UTF_8).first.to_pgn
|
|
18
|
+
expect(twice).to eq(once)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'round-trips escaped braces and backslashes' do
|
|
22
|
+
input = +"[White \"A\"]\n\n1. e4 {a \\{literal\\} back\\\\slash} *\n"
|
|
23
|
+
g = PGN.parse(input, Encoding::UTF_8).first
|
|
24
|
+
expect(g.moves.first.comment).to eq('a {literal} back\\slash')
|
|
25
|
+
reparsed = PGN.parse(g.to_pgn, Encoding::UTF_8).first
|
|
26
|
+
expect(reparsed.moves.first.comment).to eq(g.moves.first.comment)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'round-trips the nested_comments fixture comment body' do
|
|
30
|
+
input = File.binread('spec/pgn_files/nested_comments.pgn')
|
|
31
|
+
g = PGN.parse(input, Encoding::UTF_8).first
|
|
32
|
+
reparsed = PGN.parse(g.to_pgn, Encoding::UTF_8).first
|
|
33
|
+
expect(reparsed.moves.map(&:comment)).to eq(g.moves.map(&:comment))
|
|
34
|
+
end
|
|
35
|
+
end
|
data/spec/epd_spec.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PGN::EPD do
|
|
6
|
+
let(:start_epd) { 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -' }
|
|
7
|
+
|
|
8
|
+
it 'parses the placement/side/castling/ep fields' do
|
|
9
|
+
epd = PGN::EPD.new(start_epd)
|
|
10
|
+
pos = epd.to_position
|
|
11
|
+
expect(pos.player).to eq(:white)
|
|
12
|
+
expect(pos.castling).to eq(%w[K Q k q])
|
|
13
|
+
expect(pos.en_passant).to be_nil
|
|
14
|
+
expect(pos.halfmove).to eq(0)
|
|
15
|
+
expect(pos.fullmove).to eq(1)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'round-trips a simple position' do
|
|
19
|
+
expect(PGN::EPD.new(start_epd).to_s).to eq(start_epd)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'preserves trailing operation fields verbatim' do
|
|
23
|
+
epd = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - bm e4; id "start";'
|
|
24
|
+
expect(PGN::EPD.new(epd).to_s).to eq(epd)
|
|
25
|
+
expect(PGN::EPD.new(epd).ops).to eq('bm e4; id "start";')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'normalizes nil castling/ep to "-"' do
|
|
29
|
+
epd = PGN::EPD.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w')
|
|
30
|
+
expect(epd.castling).to eq('-')
|
|
31
|
+
expect(epd.en_passant).to eq('-')
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
RSpec.describe PGN::FEN, '#to_epd' do
|
|
36
|
+
it 'drops the move counters from the start position' do
|
|
37
|
+
expect(PGN::FEN.start.to_epd).to eq('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'preserves castling and en passant' do
|
|
41
|
+
fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
|
|
42
|
+
expect(PGN::FEN.new(fen).to_epd).to eq('rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6')
|
|
43
|
+
end
|
|
44
|
+
end
|
data/spec/fen_spec.rb
CHANGED
|
@@ -1,120 +1,126 @@
|
|
|
1
|
-
require
|
|
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
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'preserves the en passant square through FEN#to_position' do
|
|
52
|
+
fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
|
|
53
|
+
expect(PGN::FEN.new(fen).to_position.en_passant).to eq('d6')
|
|
54
|
+
expect(PGN::FEN.new(fen).to_position.to_fen.to_s).to eq(fen)
|
|
49
55
|
end
|
|
50
56
|
end
|
|
51
57
|
|
|
52
|
-
describe
|
|
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 ==
|
|
58
|
+
describe 'halfmove counter' do
|
|
59
|
+
it 'should reset after a pawn advance' do
|
|
60
|
+
pos = PGN::FEN.new('2b2rk1/2pp1ppp/1p6/r3P2q/3Q4/2P5/PP3PPP/RN3RK1 w - - 3 15').to_position
|
|
61
|
+
pos.to_fen.halfmove.should
|
|
62
|
+
next_pos = pos.move('f4')
|
|
63
|
+
next_pos.to_fen.halfmove.should == '0'
|
|
58
64
|
end
|
|
59
65
|
|
|
60
|
-
it
|
|
61
|
-
pos = PGN::FEN.new(
|
|
62
|
-
pos.to_fen.halfmove.should
|
|
63
|
-
next_pos = pos.move(
|
|
64
|
-
next_pos.to_fen.halfmove.should ==
|
|
66
|
+
it 'should reset after a capture' do
|
|
67
|
+
pos = PGN::FEN.new('2r2rk1/1p2ppbp/1q1p1np1/pN4B1/Pnb1PP2/2N5/1PP1B1PP/R2Q1R1K w - - 5 14').to_position
|
|
68
|
+
pos.to_fen.halfmove.should
|
|
69
|
+
next_pos = pos.move('Bxc4')
|
|
70
|
+
next_pos.to_fen.halfmove.should == '0'
|
|
65
71
|
end
|
|
66
72
|
|
|
67
|
-
it
|
|
68
|
-
pos = PGN::FEN.new(
|
|
69
|
-
pos.to_fen.halfmove.should
|
|
70
|
-
moves = %w
|
|
73
|
+
it 'should not reset otherwise' do
|
|
74
|
+
pos = PGN::FEN.new('2k2b1r/p4p1p/q1p2p2/8/2br4/5P2/PPQB2PP/R1N1K2R w KQ - 0 17').to_position
|
|
75
|
+
pos.to_fen.halfmove.should
|
|
76
|
+
moves = %w[Qf5+ Rd7 Bc3 Bh6 Qa5 Re8+ Kf2 Be3+ Kg3 Rg8+ Kh4]
|
|
71
77
|
moves.each_with_index do |move, i|
|
|
72
78
|
pos = pos.move(move)
|
|
73
|
-
pos.to_fen.halfmove.should == (i+1).to_s
|
|
79
|
+
pos.to_fen.halfmove.should == (i + 1).to_s
|
|
74
80
|
end
|
|
75
81
|
end
|
|
76
82
|
end
|
|
77
83
|
|
|
78
|
-
describe
|
|
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 ==
|
|
84
|
+
describe 'fullmove counter' do
|
|
85
|
+
it 'should not increase after white moves' do
|
|
86
|
+
pos = PGN::FEN.new('4br1k/ppqnr1b1/3p3p/P1pP1p2/2P1pB2/6PP/1P2BP1N/R2QR1K1 w - - 3 25').to_position
|
|
87
|
+
pos.to_fen.fullmove.should
|
|
88
|
+
next_pos = pos.move('Qd2')
|
|
89
|
+
next_pos.to_fen.fullmove.should == '25'
|
|
84
90
|
end
|
|
85
91
|
|
|
86
|
-
it
|
|
87
|
-
pos = PGN::FEN.new(
|
|
88
|
-
pos.to_fen.fullmove.should
|
|
89
|
-
next_pos = pos.move(
|
|
90
|
-
next_pos.to_fen.fullmove.should ==
|
|
92
|
+
it 'should increase after black moves' do
|
|
93
|
+
pos = PGN::FEN.new('4br1k/ppqnr1b1/3p3p/P1pP1p2/2P1pB2/6PP/1P1QBP1N/R3R1K1 b - - 4 25').to_position
|
|
94
|
+
pos.to_fen.fullmove.should
|
|
95
|
+
next_pos = pos.move('Kh7')
|
|
96
|
+
next_pos.to_fen.fullmove.should == '26'
|
|
91
97
|
end
|
|
92
98
|
end
|
|
93
99
|
|
|
94
|
-
describe
|
|
95
|
-
it
|
|
100
|
+
describe 'displaying FEN notation' do
|
|
101
|
+
it 'should return a string on inspect' do
|
|
96
102
|
fen = PGN::FEN.start
|
|
97
103
|
fen.inspect.should be_a(String)
|
|
98
104
|
end
|
|
99
105
|
end
|
|
100
106
|
|
|
101
|
-
describe
|
|
107
|
+
describe 'board_string round-trip' do
|
|
102
108
|
fens = [
|
|
103
109
|
PGN::FEN::INITIAL,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6',
|
|
111
|
+
'8/8/8/8/8/8/8/8 w - - 0 1',
|
|
112
|
+
'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1',
|
|
113
|
+
'4k3/4P3/8/8/8/8/8/4K3 w - - 0 1',
|
|
114
|
+
'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
|
|
109
115
|
]
|
|
110
116
|
|
|
111
|
-
it
|
|
117
|
+
it 'round-trips every fixture through FEN#to_s' do
|
|
112
118
|
fens.each do |fen|
|
|
113
119
|
expect(PGN::FEN.new(fen).to_s).to eq(fen), fen
|
|
114
120
|
end
|
|
115
121
|
end
|
|
116
122
|
|
|
117
|
-
it
|
|
123
|
+
it 'round-trips the board portion through FEN#to_position.to_fen' do
|
|
118
124
|
fens.each do |fen|
|
|
119
125
|
original = PGN::FEN.new(fen)
|
|
120
126
|
roundtrip = original.to_position.to_fen
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PGN::Game, 'mutable history' do
|
|
6
|
+
skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
|
|
7
|
+
|
|
8
|
+
let(:game) { PGN::Game.new(%w[e4 e5]) }
|
|
9
|
+
|
|
10
|
+
it 'appends a move with #push and invalidates the position cache' do
|
|
11
|
+
expect(game.positions.length).to eq(3) # start, e4, e5
|
|
12
|
+
game.push('Nf3')
|
|
13
|
+
expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nf3])
|
|
14
|
+
expect(game.positions.length).to eq(4)
|
|
15
|
+
expect(game.current_position.board.at('f3')).to eq('N')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'rejects an illegal move with ArgumentError' do
|
|
19
|
+
expect { game.push('e5') }.to raise_error(ArgumentError, /illegal move/)
|
|
20
|
+
expect(game.moves.length).to eq(2) # unchanged
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'removes and returns the last move with #pop' do
|
|
24
|
+
last = game.pop
|
|
25
|
+
expect(last.notation).to eq('e5')
|
|
26
|
+
expect(game.moves.map(&:notation)).to eq(%w[e4])
|
|
27
|
+
expect(game.positions.length).to eq(2)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'returns nil when popping an empty game' do
|
|
31
|
+
empty = PGN::Game.new([])
|
|
32
|
+
expect(empty.pop).to be_nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'normalizes castling on push' do
|
|
36
|
+
game = PGN::Game.new(%w[e4 e5 Nf3 Nc6 Bc4 Bc5 O-O Nf6 d3])
|
|
37
|
+
game.push('0-0')
|
|
38
|
+
expect(game.moves.last.notation).to eq('O-O')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'lets a freshly pushed game reach a checkmate outcome' do
|
|
42
|
+
game = PGN::Game.new(%w[e4 e5 Bc4 Nc6 Qh5 Nf6])
|
|
43
|
+
game.push('Qxf7#')
|
|
44
|
+
expect(game.outcome).to eq(:checkmate)
|
|
45
|
+
end
|
|
46
|
+
end
|