pgn2 1.5.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 +148 -0
- data/Gemfile +3 -0
- data/NOTICE.md +21 -0
- data/README.md +107 -5
- data/Rakefile +35 -10
- data/TODO.md +64 -0
- 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 +64 -0
- data/lib/pgn/fen.rb +35 -46
- data/lib/pgn/game.rb +22 -13
- data/lib/pgn/move.rb +19 -15
- data/lib/pgn/move_calculator.rb +13 -1
- 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 +97 -35
data/lib/pgn/serializer.rb
CHANGED
|
@@ -17,7 +17,7 @@ module PGN
|
|
|
17
17
|
|
|
18
18
|
# @return [String] a canonical PGN string ending with a trailing newline
|
|
19
19
|
def to_s
|
|
20
|
-
tag_section
|
|
20
|
+
"#{tag_section}\n\n#{movetext_section}\n"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
private
|
|
@@ -39,18 +39,16 @@ module PGN
|
|
|
39
39
|
# result, all joined by spaces.
|
|
40
40
|
def movetext_section
|
|
41
41
|
tokens = []
|
|
42
|
-
if @game.comment && !@game.comment.empty?
|
|
43
|
-
tokens << "{ #{escape_comment(@game.comment)} }"
|
|
44
|
-
end
|
|
42
|
+
tokens << "{ #{escape_comment(@game.comment)} }" if @game.comment && !@game.comment.empty?
|
|
45
43
|
line = emit_line(@game.moves, starting_fullmove, starting_player)
|
|
46
44
|
tokens << line unless line.empty?
|
|
47
45
|
tokens << result_token
|
|
48
|
-
tokens.join(
|
|
46
|
+
tokens.join(' ')
|
|
49
47
|
end
|
|
50
48
|
|
|
51
49
|
# The result token: the game's result if present and non-empty, else "*".
|
|
52
50
|
def result_token
|
|
53
|
-
|
|
51
|
+
@game.result.nil? || @game.result.empty? ? '*' : @game.result
|
|
54
52
|
end
|
|
55
53
|
|
|
56
54
|
# Emit a single line (mainline or variation) of movetext, tracking the
|
|
@@ -63,12 +61,11 @@ module PGN
|
|
|
63
61
|
moves.each do |move|
|
|
64
62
|
if player == :white
|
|
65
63
|
tokens << "#{fullmove}."
|
|
66
|
-
tokens << move_token(move, fullmove, player)
|
|
67
64
|
else # black
|
|
68
65
|
need_number = prev_player.nil? || prev_had_extras || prev_player != :white
|
|
69
66
|
tokens << "#{fullmove}..." if need_number
|
|
70
|
-
tokens << move_token(move, fullmove, player)
|
|
71
67
|
end
|
|
68
|
+
tokens << move_token(move, fullmove, player)
|
|
72
69
|
|
|
73
70
|
prev_player = player
|
|
74
71
|
prev_had_extras = has_extras?(move)
|
|
@@ -76,7 +73,7 @@ module PGN
|
|
|
76
73
|
player = opposite(player)
|
|
77
74
|
end
|
|
78
75
|
|
|
79
|
-
tokens.join(
|
|
76
|
+
tokens.join(' ')
|
|
80
77
|
end
|
|
81
78
|
|
|
82
79
|
# A move token: notation plus trailing extras (annotation, comment,
|
|
@@ -85,13 +82,11 @@ module PGN
|
|
|
85
82
|
def move_token(move, fullmove, player)
|
|
86
83
|
parts = [move.notation]
|
|
87
84
|
(move.annotation || []).each { |a| parts << a }
|
|
88
|
-
if move.comment && !move.comment.empty?
|
|
89
|
-
parts << "{ #{escape_comment(move.comment)} }"
|
|
90
|
-
end
|
|
85
|
+
parts << "{ #{escape_comment(move.comment)} }" if move.comment && !move.comment.empty?
|
|
91
86
|
(move.variations || []).each do |variation|
|
|
92
87
|
parts << "(#{emit_line(variation, fullmove, player)})"
|
|
93
88
|
end
|
|
94
|
-
parts.join(
|
|
89
|
+
parts.join(' ')
|
|
95
90
|
end
|
|
96
91
|
|
|
97
92
|
# Whether a move carries any annotation, comment, or variation.
|
|
@@ -118,8 +113,8 @@ module PGN
|
|
|
118
113
|
# string replacement would otherwise re-interpret backslashes).
|
|
119
114
|
def escape_tag(value)
|
|
120
115
|
value.to_s
|
|
121
|
-
.gsub(
|
|
122
|
-
.gsub('"') { "
|
|
116
|
+
.gsub('\\') { '\\\\' }
|
|
117
|
+
.gsub('"') { '\"' }
|
|
123
118
|
end
|
|
124
119
|
|
|
125
120
|
# Escape backslashes and braces for a comment body (block form, so the
|
|
@@ -133,9 +128,9 @@ module PGN
|
|
|
133
128
|
# acknowledged v1 limitation.
|
|
134
129
|
def escape_comment(text)
|
|
135
130
|
text.to_s
|
|
136
|
-
.gsub(
|
|
137
|
-
.gsub(
|
|
138
|
-
.gsub(
|
|
131
|
+
.gsub('\\') { '\\\\' }
|
|
132
|
+
.gsub('{') { '\\{' }
|
|
133
|
+
.gsub('}') { '\\}' }
|
|
139
134
|
end
|
|
140
135
|
end
|
|
141
136
|
end
|
data/lib/pgn/version.rb
CHANGED
data/lib/pgn/zobrist.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGN
|
|
4
|
+
# Zobrist hashing keys for incremental position hashing. All keys are
|
|
5
|
+
# fixed 64-bit pseudo-random Integers generated once at load time from a
|
|
6
|
+
# frozen seed so hashes are stable for the life of the process.
|
|
7
|
+
#
|
|
8
|
+
# Indexing is by 0x88 square index (0..127); off-board indices are
|
|
9
|
+
# allocated but never read.
|
|
10
|
+
module Zobrist
|
|
11
|
+
SEED = 0x1234_5678_9abc_def1
|
|
12
|
+
|
|
13
|
+
# Deterministic pseudo-random generator so hashes are stable per
|
|
14
|
+
# process and across machines (no Kernel#rand).
|
|
15
|
+
def self.gen
|
|
16
|
+
@gen ||= Random.new(SEED)
|
|
17
|
+
end
|
|
18
|
+
private_class_method :gen
|
|
19
|
+
|
|
20
|
+
def self.rand64
|
|
21
|
+
gen.rand(1 << 64)
|
|
22
|
+
end
|
|
23
|
+
private_class_method :rand64
|
|
24
|
+
|
|
25
|
+
PIECES = %w[P N B R Q K p n b r q k].freeze
|
|
26
|
+
|
|
27
|
+
TABLE = PIECES.to_h { |piece| [piece, Array.new(128) { rand64 }] }.freeze
|
|
28
|
+
|
|
29
|
+
SIDE = rand64
|
|
30
|
+
CASTLING = { 'K' => rand64, 'Q' => rand64, 'k' => rand64, 'q' => rand64 }.freeze
|
|
31
|
+
EP_FILE = Array.new(8) { rand64 }.freeze
|
|
32
|
+
|
|
33
|
+
# @param board [PGN::Board]
|
|
34
|
+
# @param player [Symbol] :white or :black
|
|
35
|
+
# @param castling [Array<String>] e.g. %w[K Q k q]
|
|
36
|
+
# @param en_passant [String, nil] e.g. "e3" or nil
|
|
37
|
+
# @return [Integer] the Zobrist hash of the position
|
|
38
|
+
def self.seed(board, player, castling, en_passant)
|
|
39
|
+
h = 0
|
|
40
|
+
0.upto(7) do |rank|
|
|
41
|
+
0.upto(7) do |file|
|
|
42
|
+
idx = board.index_for(file, rank)
|
|
43
|
+
piece = board.at_index(idx)
|
|
44
|
+
h ^= TABLE[piece][idx] if piece
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
h ^= SIDE if player == :black
|
|
48
|
+
castling.to_a.each { |right| h ^= CASTLING[right] if CASTLING.key?(right) }
|
|
49
|
+
h ^= EP_FILE[Board::FILE_TO_INDEX[en_passant[0]]] if en_passant && !en_passant.empty?
|
|
50
|
+
h
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/pgn.rb
CHANGED
|
@@ -5,11 +5,13 @@ require 'pgn/move'
|
|
|
5
5
|
require 'pgn/move_calculator'
|
|
6
6
|
require 'pgn/lexer'
|
|
7
7
|
require 'pgn/notation'
|
|
8
|
+
require 'pgn/zobrist'
|
|
8
9
|
require 'pgn/pgn_parser'
|
|
9
10
|
require 'pgn/parser'
|
|
10
11
|
require 'pgn/position'
|
|
11
12
|
require 'pgn/serializer'
|
|
12
13
|
require 'pgn/version'
|
|
14
|
+
require 'pgn/bitboard'
|
|
13
15
|
|
|
14
16
|
module PGN
|
|
15
17
|
# @param pgn [String] a pgn representation of one or more chess games
|
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
|
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
|