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/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,10 +123,70 @@ 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
|
|
93
130
|
end
|
|
94
131
|
end
|
|
132
|
+
|
|
133
|
+
describe 'node mutation round-trip' do
|
|
134
|
+
non_round_trip = %w[doublequotes.pgn specialcharacters.pgn].freeze
|
|
135
|
+
|
|
136
|
+
# A node-sig tree with variations sorted, so the comparison is
|
|
137
|
+
# order-independent (the parser reverses variation order on each parse).
|
|
138
|
+
def node_sig(move)
|
|
139
|
+
[move.notation, (move.variations || []).map { line_sig(_1) }.sort]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def line_sig(line)
|
|
143
|
+
line.map { node_sig(_1) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def tree_sig(game)
|
|
147
|
+
game.moves.map { node_sig(_1) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# First node (DFS over children) that has at least one variation. Such a
|
|
151
|
+
# node always has a non-nil continuation, so add_variation can branch.
|
|
152
|
+
def first_branch_with_variations(node)
|
|
153
|
+
stack = [node]
|
|
154
|
+
until stack.empty?
|
|
155
|
+
n = stack.shift
|
|
156
|
+
return n unless n.variations.empty?
|
|
157
|
+
|
|
158
|
+
stack.concat(n.children)
|
|
159
|
+
end
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'every fixture survives promote/demote/add_variation and re-parses' do
|
|
164
|
+
fixtures = `git ls-files spec/pgn_files/`.split.map { |p| File.expand_path(p) }
|
|
165
|
+
fixtures.reject! { |p| non_round_trip.include?(File.basename(p)) }
|
|
166
|
+
fixtures.each do |path|
|
|
167
|
+
PGN.parse(File.read(path, encoding: Encoding::ISO_8859_1)).each do |game|
|
|
168
|
+
root = game.root
|
|
169
|
+
|
|
170
|
+
# promote then demote the first variation if one exists
|
|
171
|
+
branch = first_branch_with_variations(root)
|
|
172
|
+
if branch && !branch.variations.empty?
|
|
173
|
+
branch.variations.first.promote
|
|
174
|
+
game.root
|
|
175
|
+
b2 = first_branch_with_variations(game.root)
|
|
176
|
+
if b2 && !b2.variations.empty?
|
|
177
|
+
b2.variations.first.demote
|
|
178
|
+
game.root
|
|
179
|
+
end
|
|
180
|
+
b3 = first_branch_with_variations(game.root)
|
|
181
|
+
b3&.add_variation('Nc3')
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
once = game.to_pgn
|
|
185
|
+
reparsed = PGN.parse(once).first
|
|
186
|
+
expect(tree_sig(reparsed)).to eq(tree_sig(game)),
|
|
187
|
+
"#{path}: mutated game tree must round-trip (set-stable)"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
95
192
|
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"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PGN::MoveText, '#clean_text' do
|
|
6
|
+
it 'strips a single outermost brace pair' do
|
|
7
|
+
mt = PGN::MoveText.new('e4', nil, '{a comment}')
|
|
8
|
+
expect(mt.comment).to eq('a comment')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'is idempotent on a nested-brace comment' do
|
|
12
|
+
mt = PGN::MoveText.new('e4', nil, '{a {b} c}')
|
|
13
|
+
once = mt.comment
|
|
14
|
+
# Re-running clean_text (as MoveText.new would) must not strip the inner braces.
|
|
15
|
+
twice = PGN::MoveText.new('e4', nil, once).comment
|
|
16
|
+
expect(twice).to eq(once)
|
|
17
|
+
expect(twice).to eq('a {b} c')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'collapses whitespace and strips' do
|
|
21
|
+
mt = PGN::MoveText.new('e4', nil, "{ multi\nline }")
|
|
22
|
+
expect(mt.comment).to eq('multi line')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'returns nil for a nil comment' do
|
|
26
|
+
expect(PGN::MoveText.new('e4').comment).to be_nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
RSpec.describe PGN::Game, '#moves= with braced comments' do
|
|
31
|
+
it 'reuses a MoveText unchanged when no castling fix is needed' do
|
|
32
|
+
mt = PGN::MoveText.new('e4', nil, 'a {b} c')
|
|
33
|
+
game = PGN::Game.new([mt])
|
|
34
|
+
expect(game.moves.first).to be(mt)
|
|
35
|
+
expect(game.moves.first.comment).to eq('a {b} c')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'rebuilds without corrupting a nested-brace comment when fixing castling' do
|
|
39
|
+
mt = PGN::MoveText.new('0-0', nil, 'a {b} c')
|
|
40
|
+
game = PGN::Game.new([mt])
|
|
41
|
+
expect(game.moves.first.notation).to eq('O-O')
|
|
42
|
+
expect(game.moves.first.comment).to eq('a {b} c')
|
|
43
|
+
end
|
|
44
|
+
end
|
data/spec/node_spec.rb
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
# Helpers for order-independent variation comparison. The parser's
|
|
4
|
+
# `variation_list` rule is right-recursive, so a move's variations are stored
|
|
5
|
+
# in reverse of their PGN-text order; round-tripping flips that order each
|
|
6
|
+
# parse. We therefore compare variation trees as sorted signatures (the same
|
|
7
|
+
# approach the existing game_spec round-trip gate uses), and reference
|
|
8
|
+
# variations by notation rather than by index.
|
|
9
|
+
def node_sig(move)
|
|
10
|
+
[move.notation, (move.variations || []).map { line_sig(_1) }.sort]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def line_sig(line)
|
|
14
|
+
line.map { node_sig(_1) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def tree_sig(game)
|
|
18
|
+
game.moves.map { node_sig(_1) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe PGN::Node do
|
|
22
|
+
let(:game) { PGN.parse(File.read(File.expand_path('pgn_files/variations.pgn', __dir__), encoding: Encoding::ISO_8859_1)).first }
|
|
23
|
+
let(:root) { game.root }
|
|
24
|
+
|
|
25
|
+
it 'root is a root node with no move' do
|
|
26
|
+
expect(root).to be_root
|
|
27
|
+
expect(root.move).to be_nil
|
|
28
|
+
expect(root.notation).to be_nil
|
|
29
|
+
expect(root.previous).to be_nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'main_line matches game.moves notations (root excluded)' do
|
|
33
|
+
expect(root.main_line.map(&:notation)).to eq(game.moves.map(&:notation))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'main_line is an Enumerator without a block' do
|
|
37
|
+
expect(root.main_line).to be_an(Enumerator)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'children of the position after 1.e4 e5 are Nf3 (main), Nc3, f4 (any order)' do
|
|
41
|
+
node = root.next.next # after e4 (white), after e5 (black) => before Nf3
|
|
42
|
+
expect(node.children.map(&:notation)).to contain_exactly('Nf3', 'Nc3', 'f4')
|
|
43
|
+
expect(node.next.notation).to eq('Nf3') # mainline continuation is first
|
|
44
|
+
expect(node.variations.map(&:notation)).to contain_exactly('Nc3', 'f4')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'nested variation f5 is a sibling of d5 under the Nc3 node' do
|
|
48
|
+
nc3_node = root.next.next.children.find { |n| n.notation == 'Nc3' }
|
|
49
|
+
expect(nc3_node.children.map(&:notation)).to contain_exactly('d5', 'f5')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'previous walks back to the root' do
|
|
53
|
+
node = root.next.next.next # Nf3 node
|
|
54
|
+
expect(node.previous.notation).to eq('e5')
|
|
55
|
+
expect(node.previous.previous.notation).to eq('e4')
|
|
56
|
+
expect(node.previous.previous.previous).to be_root
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe '#position' do
|
|
60
|
+
it 'root position is the starting position' do
|
|
61
|
+
expect(root.position.to_fen.to_s).to eq(PGN::Position.start.to_fen.to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'main_line positions match game.positions[1..]' do
|
|
65
|
+
expect(root.main_line.map { |n| n.position.to_fen.to_s })
|
|
66
|
+
.to eq(game.positions[1..].map { |p| p.to_fen.to_s })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'a variation node position is replayed from the root' do
|
|
70
|
+
# After 1.e4 e5 2.Nc3 (the Nc3 variation first move)
|
|
71
|
+
nc3_node = root.next.next.children.find { |n| n.notation == 'Nc3' }
|
|
72
|
+
expected = PGN::Position.start.move('e4').move('e5').move('Nc3')
|
|
73
|
+
expect(nc3_node.position.to_fen.to_s).to eq(expected.to_fen.to_s)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'is cached (returns the same object on second call)' do
|
|
77
|
+
node = root.next
|
|
78
|
+
expect(node.position).to equal(node.position)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe '#add_variation' do
|
|
83
|
+
it 'appends a single-SAN variation branching before the next move' do
|
|
84
|
+
root.next.next.add_variation('Nc6')
|
|
85
|
+
expect(game.moves[2].variations.map { |v| v.map(&:notation) })
|
|
86
|
+
.to include(['Nc6'], %w[Nc3 d5 exd5], %w[f4 exf4])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'appends a multi-move variation line' do
|
|
90
|
+
root.next.next.add_variation(%w[Nc6 Bc4])
|
|
91
|
+
expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nc6 Bc4])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'normalizes castling 0 -> O' do
|
|
95
|
+
g = PGN::Game.new(%w[e4 e5 Nf3])
|
|
96
|
+
g.root.next.next.add_variation('0-0')
|
|
97
|
+
expect(g.moves[2].variations.map { |v| v.map(&:notation) }).to include(['O-O'])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'round-trips (variation set stable under re-parse)' do
|
|
101
|
+
root.next.next.add_variation(%w[Nc6 Bc4])
|
|
102
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
103
|
+
expect(tree_sig(reparsed)).to eq(tree_sig(game))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
describe '#add_variation at a terminal node' do
|
|
108
|
+
it 'raises ArgumentError' do
|
|
109
|
+
g = PGN::Game.new(%w[e4 e5])
|
|
110
|
+
last = g.root.main_line.to_a.last
|
|
111
|
+
expect { last.add_variation('Nf6') }.to raise_error(ArgumentError)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe '#add_main_variation' do
|
|
116
|
+
it 'extends the line at a terminal node' do
|
|
117
|
+
g = PGN::Game.new(%w[e4 e5])
|
|
118
|
+
g.root.main_line.to_a.last.add_main_variation('Nf3')
|
|
119
|
+
expect(g.moves.map(&:notation)).to eq(%w[e4 e5 Nf3])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'inserts a new mainline move and demotes the old continuation to a variation' do
|
|
123
|
+
node = root.next.next # position before Nf3
|
|
124
|
+
node.add_main_variation('Nc6')
|
|
125
|
+
expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nc6])
|
|
126
|
+
# old mainline Nf3 + Nf6 is now a variation of Nc6
|
|
127
|
+
expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nf3 Nf6])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
describe 'variation reordering' do
|
|
132
|
+
# Storage order of Nf3's variations is reverse of the PGN text
|
|
133
|
+
# (`[f4-line, Nc3-line]`) because the parser reverses variation order.
|
|
134
|
+
# Tests reference variations by notation, never by index.
|
|
135
|
+
let(:branch) { root.next.next }
|
|
136
|
+
let(:nf3) { game.moves[2] }
|
|
137
|
+
|
|
138
|
+
def find_variation(notation)
|
|
139
|
+
branch.children.find { |n| n.notation == notation }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'promote moves a variation one slot toward mainline' do
|
|
143
|
+
find_variation('Nc3').promote
|
|
144
|
+
# Nc3 was behind f4 in storage; now it is first (just after the mainline)
|
|
145
|
+
expect(nf3.variations.first.first.notation).to eq('Nc3')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'promote is a no-op for the first variation' do
|
|
149
|
+
find_variation('f4').promote # f4 is already the first stored variation
|
|
150
|
+
expect(nf3.variations.first.first.notation).to eq('f4')
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'demote moves a variation one slot toward the end' do
|
|
154
|
+
find_variation('f4').demote
|
|
155
|
+
expect(nf3.variations.last.first.notation).to eq('f4')
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it 'demote at index 0 (mainline) is the inverse swap' do
|
|
159
|
+
branch.next.demote # Nf3 (mainline) -> variation #1
|
|
160
|
+
# the first stored variation (f4) becomes the new mainline
|
|
161
|
+
expect(game.moves[2].notation).to eq('f4')
|
|
162
|
+
expect(game.moves[3].notation).to eq('exf4')
|
|
163
|
+
# old mainline Nf3 + Nf6 is now the first variation of f4
|
|
164
|
+
expect(game.moves[2].variations.first.map(&:notation)).to eq(%w[Nf3 Nf6])
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'promote_to_main makes a variation the new mainline' do
|
|
168
|
+
find_variation('Nc3').promote_to_main
|
|
169
|
+
expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nc3 d5 exd5])
|
|
170
|
+
# old mainline Nf3 + Nf6 is now a variation of Nc3
|
|
171
|
+
expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nf3 Nf6])
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it 'demote_to_last moves a variation to the end' do
|
|
175
|
+
find_variation('f4').demote_to_last
|
|
176
|
+
expect(nf3.variations.last.first.notation).to eq('f4')
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it 'demote_to_last on the mainline makes it the last variation' do
|
|
180
|
+
branch.next.demote_to_last # Nf3 -> last variation
|
|
181
|
+
# the last stored variation (Nc3) is promoted to mainline
|
|
182
|
+
expect(game.moves[2].notation).to eq('Nc3')
|
|
183
|
+
# old mainline Nf3 + Nf6 is the LAST variation of Nc3
|
|
184
|
+
expect(game.moves[2].variations.last.map(&:notation)).to eq(%w[Nf3 Nf6])
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'every reorder round-trips (variation set stable under re-parse)' do
|
|
188
|
+
find_variation('Nc3').promote_to_main
|
|
189
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
190
|
+
expect(tree_sig(reparsed)).to eq(tree_sig(game))
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
describe 'flatten-on-mutation for same-point nested variations' do
|
|
195
|
+
it 'flattens ( V1 ( V2 ) ) into flat siblings on promote_to_main' do
|
|
196
|
+
# 1. e4 ( e5 ( e6 ) ) ( d5 ) — e5, e6, d5 all branch before e4 (at root).
|
|
197
|
+
g = PGN.parse(%([White "x"]\n\n1. e4 ( e5 ( e6 ) ) ( d5 ) *\n).force_encoding(Encoding::ISO_8859_1)).first
|
|
198
|
+
expect(g.root.children.map(&:notation)).to contain_exactly('e4', 'e5', 'e6', 'd5')
|
|
199
|
+
e6 = g.root.children.find { |n| n.notation == 'e6' }
|
|
200
|
+
e6.promote_to_main
|
|
201
|
+
# e6 is now the mainline; e4, e5, d5 are flat variations of e6
|
|
202
|
+
expect(g.moves.map(&:notation)).to eq(['e6'])
|
|
203
|
+
expect(g.moves[0].variations.map { |v| v.first.notation }).to contain_exactly('e4', 'e5', 'd5')
|
|
204
|
+
reparsed = PGN.parse(g.to_pgn).first
|
|
205
|
+
expect(tree_sig(reparsed)).to eq(tree_sig(g))
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
describe '#delete' do
|
|
210
|
+
let(:branch) { root.next.next }
|
|
211
|
+
let(:nf3) { game.moves[2] }
|
|
212
|
+
|
|
213
|
+
it 'removes a variation' do
|
|
214
|
+
branch.children.find { |n| n.notation == 'f4' }.delete
|
|
215
|
+
expect(nf3.variations.map { |v| v.first.notation }).to contain_exactly('Nc3')
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it 'removing the mainline continuation promotes the first variation' do
|
|
219
|
+
branch.next.delete # delete Nf3 (mainline)
|
|
220
|
+
# the first stored variation (f4) takes its place
|
|
221
|
+
expect(game.moves[2].notation).to eq('f4')
|
|
222
|
+
expect(game.moves[3].notation).to eq('exf4')
|
|
223
|
+
# the remaining variation (Nc3) is now f4's variation
|
|
224
|
+
expect(game.moves[2].variations.map { |v| v.first.notation }).to contain_exactly('Nc3')
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'removing the mainline continuation with no variations truncates' do
|
|
228
|
+
g = PGN::Game.new(%w[e4 e5 Nf3 Nf6])
|
|
229
|
+
nf3 = g.root.main_line.to_a[2]
|
|
230
|
+
nf3.delete
|
|
231
|
+
expect(g.moves.map(&:notation)).to eq(%w[e4 e5])
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it 'round-trips after delete' do
|
|
235
|
+
branch.children.find { |n| n.notation == 'f4' }.delete
|
|
236
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
237
|
+
expect(tree_sig(reparsed)).to eq(tree_sig(game))
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
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')
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PGN::Position, 'outcome' do
|
|
6
|
+
skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
|
|
7
|
+
|
|
8
|
+
it 'is nil for the start position' do
|
|
9
|
+
expect(PGN::Position.start.outcome).to be_nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'detects checkmate (fool\'s mate)' do
|
|
13
|
+
pos = PGN::FEN.new('rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3').to_position
|
|
14
|
+
expect(pos.outcome).to eq(:checkmate)
|
|
15
|
+
expect(pos.checkmate?).to be(true)
|
|
16
|
+
expect(pos.stalemate?).to be(false)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'detects stalemate' do
|
|
20
|
+
pos = PGN::FEN.new('7k/5Q2/6K1/8/8/8/8/8 b - - 0 1').to_position
|
|
21
|
+
expect(pos.outcome).to eq(:stalemate)
|
|
22
|
+
expect(pos.stalemate?).to be(true)
|
|
23
|
+
expect(pos.in_check?).to be(false)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'detects insufficient material: K vs K' do
|
|
27
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/8/8/4K3 w - - 0 1').to_position
|
|
28
|
+
expect(pos.insufficient_material?).to be(true)
|
|
29
|
+
expect(pos.outcome).to eq(:draw)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'detects insufficient material: K+B vs K' do
|
|
33
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/8/8/3BK3 w - - 0 1').to_position
|
|
34
|
+
expect(pos.insufficient_material?).to be(true)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'detects insufficient material: K+N vs K' do
|
|
38
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/8/8/3NK3 w - - 0 1').to_position
|
|
39
|
+
expect(pos.insufficient_material?).to be(true)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'does not consider K+Q vs K insufficient' do
|
|
43
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/8/8/3QK3 w - - 0 1').to_position
|
|
44
|
+
expect(pos.insufficient_material?).to be(false)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'detects the 50-move rule' do
|
|
48
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/8/8/4K3 w - - 100 60').to_position
|
|
49
|
+
expect(pos.fifty_move?).to be(true)
|
|
50
|
+
expect(pos.outcome).to eq(:draw)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'does not trigger 50-move before 100 halfmoves' do
|
|
54
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/8/8/4K3 w - - 99 60').to_position
|
|
55
|
+
expect(pos.fifty_move?).to be(false)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
RSpec.describe PGN::Game, 'outcome' do
|
|
60
|
+
skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
|
|
61
|
+
|
|
62
|
+
it 'is nil for a game in progress' do
|
|
63
|
+
game = PGN::Game.new(%w[e4 e5 Nf3 Nc6])
|
|
64
|
+
expect(game.outcome).to be_nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'detects a checkmate game (scholar\'s mate)' do
|
|
68
|
+
game = PGN::Game.new(%w[e4 e5 Bc4 Nc6 Qh5 Nf6 Qxf7])
|
|
69
|
+
expect(game.outcome).to eq(:checkmate)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'detects a stalemate game' do
|
|
73
|
+
# Black king h8, white queen f7, white king g6; black to move is stalemated.
|
|
74
|
+
game = PGN::Game.new(
|
|
75
|
+
%w[],
|
|
76
|
+
{ 'FEN' => '7k/5Q2/6K1/8/8/8/8/8 b - - 0 1' },
|
|
77
|
+
'1/2-1/2'
|
|
78
|
+
)
|
|
79
|
+
expect(game.outcome).to eq(:stalemate)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'detects threefold repetition' do
|
|
83
|
+
# 1. Nf3 Nf6 2. Ng1 Ng8 repeats the start position three times.
|
|
84
|
+
game = PGN::Game.new(%w[Nf3 Nf6 Ng1 Ng8 Nf3 Nf6 Ng1 Ng8])
|
|
85
|
+
expect(game.threefold?).to be(true)
|
|
86
|
+
expect(game.outcome).to eq(:draw)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'does not claim threefold without three repeats' do
|
|
90
|
+
game = PGN::Game.new(%w[Nf3 Nf6 Ng1])
|
|
91
|
+
expect(game.threefold?).to be(false)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe 'left-recursive parser list rules' do
|
|
6
|
+
let(:pgn) do
|
|
7
|
+
+<<~PGN
|
|
8
|
+
[Event "A"]
|
|
9
|
+
[Site "B"]
|
|
10
|
+
[Event "C"]
|
|
11
|
+
[White "W"]
|
|
12
|
+
[Black "X"]
|
|
13
|
+
|
|
14
|
+
1. e4 (1...c5 (1...d5)) (1...e5) *
|
|
15
|
+
PGN
|
|
16
|
+
end
|
|
17
|
+
let(:game) { PGN.parse(pgn).first }
|
|
18
|
+
|
|
19
|
+
it 'preserves the legacy reversed tag order' do
|
|
20
|
+
expect(game.tags.keys).to eq(%w[Black White Event Site])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'keeps first-occurrence-wins on duplicate tags' do
|
|
24
|
+
expect(game.tags['Event']).to eq('A')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'preserves the legacy reversed variation order' do
|
|
28
|
+
variations = game.moves.first.variations.map { |v| v.map(&:notation) }
|
|
29
|
+
expect(variations).to eq([%w[e5], %w[c5]])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'keeps nested variations inside their parent' do
|
|
33
|
+
c5 = game.moves.first.variations.last
|
|
34
|
+
expect(c5.map(&:notation)).to eq(%w[c5])
|
|
35
|
+
expect(c5.first.variations.map { |v| v.map(&:notation) }).to eq([%w[d5]])
|
|
36
|
+
end
|
|
37
|
+
end
|
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
|