pgn2 2.0.0 → 2.0.2
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/release-gems.yml +5 -2
- data/CHANGELOG.md +101 -2
- data/README.md +27 -0
- data/Rakefile +18 -0
- data/TODO.md +25 -107
- 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-15-game-tree-api-design.md +387 -0
- data/ext/pgn2_native/pgn2-bitboard/src/board.rs +2 -10
- data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +0 -2
- data/ext/pgn2_native/pgn2_native/src/lib.rs +5 -5
- data/lib/pgn/attack.rb +107 -0
- data/lib/pgn/epd.rb +54 -0
- data/lib/pgn/fen.rb +54 -44
- data/lib/pgn/game.rb +91 -7
- data/lib/pgn/move.rb +4 -4
- data/lib/pgn/node.rb +347 -0
- data/lib/pgn/notation.rb +8 -66
- data/lib/pgn/pgn_parser.rb +30 -31
- data/lib/pgn/pgn_parser.y +14 -15
- data/lib/pgn/position.rb +193 -0
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +3 -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 +6 -0
- data/spec/game_history_spec.rb +46 -0
- data/spec/game_spec.rb +46 -0
- data/spec/movetext_clean_spec.rb +44 -0
- data/spec/node_spec.rb +222 -0
- data/spec/outcome_spec.rb +93 -0
- data/spec/parser_left_recursion_spec.rb +37 -0
- data/spec/position_attack_spec.rb +56 -0
- data/spec/position_legal_spec.rb +123 -0
- data/spec/spec_helper.rb +17 -0
- metadata +18 -3
- data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +0 -121
|
@@ -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
|
data/spec/game_spec.rb
CHANGED
|
@@ -129,4 +129,50 @@ describe PGN::Game do
|
|
|
129
129
|
end
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
|
+
|
|
133
|
+
describe 'node mutation round-trip' do
|
|
134
|
+
non_round_trip = %w[doublequotes.pgn specialcharacters.pgn].freeze
|
|
135
|
+
|
|
136
|
+
# First node (DFS over children) that has at least one variation. Such a
|
|
137
|
+
# node always has a non-nil continuation, so add_variation can branch.
|
|
138
|
+
def first_branch_with_variations(node)
|
|
139
|
+
stack = [node]
|
|
140
|
+
until stack.empty?
|
|
141
|
+
n = stack.shift
|
|
142
|
+
return n unless n.variations.empty?
|
|
143
|
+
|
|
144
|
+
stack.concat(n.children)
|
|
145
|
+
end
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'every fixture survives promote/demote/add_variation and re-parses' do
|
|
150
|
+
fixtures = `git ls-files spec/pgn_files/`.split.map { |p| File.expand_path(p) }
|
|
151
|
+
fixtures.reject! { |p| non_round_trip.include?(File.basename(p)) }
|
|
152
|
+
fixtures.each do |path|
|
|
153
|
+
PGN.parse(File.read(path, encoding: Encoding::ISO_8859_1)).each do |game|
|
|
154
|
+
root = game.root
|
|
155
|
+
|
|
156
|
+
# promote then demote the first variation if one exists
|
|
157
|
+
branch = first_branch_with_variations(root)
|
|
158
|
+
if branch && !branch.variations.empty?
|
|
159
|
+
branch.variations.first.promote
|
|
160
|
+
game.root
|
|
161
|
+
b2 = first_branch_with_variations(game.root)
|
|
162
|
+
if b2 && !b2.variations.empty?
|
|
163
|
+
b2.variations.first.demote
|
|
164
|
+
game.root
|
|
165
|
+
end
|
|
166
|
+
b3 = first_branch_with_variations(game.root)
|
|
167
|
+
b3&.add_variation('Nc3')
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
once = game.to_pgn
|
|
171
|
+
reparsed = PGN.parse(once).first
|
|
172
|
+
expect(tree_sig(reparsed)).to eq(tree_sig(game)),
|
|
173
|
+
"#{path}: mutated game tree must round-trip (set-stable)"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
132
178
|
end
|
|
@@ -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,222 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe PGN::Node do
|
|
4
|
+
let(:game) { PGN.parse(File.read(File.expand_path('pgn_files/variations.pgn', __dir__), encoding: Encoding::ISO_8859_1)).first }
|
|
5
|
+
let(:root) { game.root }
|
|
6
|
+
|
|
7
|
+
it 'root is a root node with no move' do
|
|
8
|
+
expect(root).to be_root
|
|
9
|
+
expect(root.move).to be_nil
|
|
10
|
+
expect(root.notation).to be_nil
|
|
11
|
+
expect(root.previous).to be_nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'main_line matches game.moves notations (root excluded)' do
|
|
15
|
+
expect(root.main_line.map(&:notation)).to eq(game.moves.map(&:notation))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'main_line is an Enumerator without a block' do
|
|
19
|
+
expect(root.main_line).to be_an(Enumerator)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'children of the position after 1.e4 e5 are Nf3 (main), Nc3, f4 (any order)' do
|
|
23
|
+
node = root.next.next # after e4 (white), after e5 (black) => before Nf3
|
|
24
|
+
expect(node.children.map(&:notation)).to contain_exactly('Nf3', 'Nc3', 'f4')
|
|
25
|
+
expect(node.next.notation).to eq('Nf3') # mainline continuation is first
|
|
26
|
+
expect(node.variations.map(&:notation)).to contain_exactly('Nc3', 'f4')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'nested variation f5 is a sibling of d5 under the Nc3 node' do
|
|
30
|
+
nc3_node = root.next.next.children.find { |n| n.notation == 'Nc3' }
|
|
31
|
+
expect(nc3_node.children.map(&:notation)).to contain_exactly('d5', 'f5')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'previous walks back to the root' do
|
|
35
|
+
node = root.next.next.next # Nf3 node
|
|
36
|
+
expect(node.previous.notation).to eq('e5')
|
|
37
|
+
expect(node.previous.previous.notation).to eq('e4')
|
|
38
|
+
expect(node.previous.previous.previous).to be_root
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe '#position' do
|
|
42
|
+
it 'root position is the starting position' do
|
|
43
|
+
expect(root.position.to_fen.to_s).to eq(PGN::Position.start.to_fen.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'main_line positions match game.positions[1..]' do
|
|
47
|
+
expect(root.main_line.map { |n| n.position.to_fen.to_s })
|
|
48
|
+
.to eq(game.positions[1..].map { |p| p.to_fen.to_s })
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'a variation node position is replayed from the root' do
|
|
52
|
+
# After 1.e4 e5 2.Nc3 (the Nc3 variation first move)
|
|
53
|
+
nc3_node = root.next.next.children.find { |n| n.notation == 'Nc3' }
|
|
54
|
+
expected = PGN::Position.start.move('e4').move('e5').move('Nc3')
|
|
55
|
+
expect(nc3_node.position.to_fen.to_s).to eq(expected.to_fen.to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'is cached (returns the same object on second call)' do
|
|
59
|
+
node = root.next
|
|
60
|
+
expect(node.position).to equal(node.position)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
describe '#add_variation' do
|
|
65
|
+
it 'appends a single-SAN variation branching before the next move' do
|
|
66
|
+
root.next.next.add_variation('Nc6')
|
|
67
|
+
expect(game.moves[2].variations.map { |v| v.map(&:notation) })
|
|
68
|
+
.to include(['Nc6'], %w[Nc3 d5 exd5], %w[f4 exf4])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'appends a multi-move variation line' do
|
|
72
|
+
root.next.next.add_variation(%w[Nc6 Bc4])
|
|
73
|
+
expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nc6 Bc4])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'normalizes castling 0 -> O' do
|
|
77
|
+
g = PGN::Game.new(%w[e4 e5 Nf3])
|
|
78
|
+
g.root.next.next.add_variation('0-0')
|
|
79
|
+
expect(g.moves[2].variations.map { |v| v.map(&:notation) }).to include(['O-O'])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'round-trips (variation set stable under re-parse)' do
|
|
83
|
+
root.next.next.add_variation(%w[Nc6 Bc4])
|
|
84
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
85
|
+
expect(tree_sig(reparsed)).to eq(tree_sig(game))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
describe '#add_variation at a terminal node' do
|
|
90
|
+
it 'raises ArgumentError' do
|
|
91
|
+
g = PGN::Game.new(%w[e4 e5])
|
|
92
|
+
last = g.root.main_line.to_a.last
|
|
93
|
+
expect { last.add_variation('Nf6') }.to raise_error(ArgumentError)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe '#add_main_variation' do
|
|
98
|
+
it 'extends the line at a terminal node' do
|
|
99
|
+
g = PGN::Game.new(%w[e4 e5])
|
|
100
|
+
g.root.main_line.to_a.last.add_main_variation('Nf3')
|
|
101
|
+
expect(g.moves.map(&:notation)).to eq(%w[e4 e5 Nf3])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'inserts a new mainline move and demotes the old continuation to a variation' do
|
|
105
|
+
node = root.next.next # position before Nf3
|
|
106
|
+
node.add_main_variation('Nc6')
|
|
107
|
+
expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nc6])
|
|
108
|
+
# old mainline Nf3 + Nf6 is now a variation of Nc6
|
|
109
|
+
expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nf3 Nf6])
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
describe 'variation reordering' do
|
|
114
|
+
# Storage order of Nf3's variations is reverse of the PGN text
|
|
115
|
+
# (`[f4-line, Nc3-line]`) because the parser reverses variation order.
|
|
116
|
+
# Tests reference variations by notation, never by index.
|
|
117
|
+
let(:branch) { root.next.next }
|
|
118
|
+
let(:nf3) { game.moves[2] }
|
|
119
|
+
|
|
120
|
+
def find_variation(notation)
|
|
121
|
+
branch.children.find { |n| n.notation == notation }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'promote moves a variation one slot toward mainline' do
|
|
125
|
+
find_variation('Nc3').promote
|
|
126
|
+
# Nc3 was behind f4 in storage; now it is first (just after the mainline)
|
|
127
|
+
expect(nf3.variations.first.first.notation).to eq('Nc3')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'promote is a no-op for the first variation' do
|
|
131
|
+
find_variation('f4').promote # f4 is already the first stored variation
|
|
132
|
+
expect(nf3.variations.first.first.notation).to eq('f4')
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it 'demote moves a variation one slot toward the end' do
|
|
136
|
+
find_variation('f4').demote
|
|
137
|
+
expect(nf3.variations.last.first.notation).to eq('f4')
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'demote at index 0 (mainline) is the inverse swap' do
|
|
141
|
+
branch.next.demote # Nf3 (mainline) -> variation #1
|
|
142
|
+
# the first stored variation (f4) becomes the new mainline
|
|
143
|
+
expect(game.moves[2].notation).to eq('f4')
|
|
144
|
+
expect(game.moves[3].notation).to eq('exf4')
|
|
145
|
+
# old mainline Nf3 + Nf6 is now the first variation of f4
|
|
146
|
+
expect(game.moves[2].variations.first.map(&:notation)).to eq(%w[Nf3 Nf6])
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'promote_to_main makes a variation the new mainline' do
|
|
150
|
+
find_variation('Nc3').promote_to_main
|
|
151
|
+
expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nc3 d5 exd5])
|
|
152
|
+
# old mainline Nf3 + Nf6 is now a variation of Nc3
|
|
153
|
+
expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nf3 Nf6])
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'demote_to_last moves a variation to the end' do
|
|
157
|
+
find_variation('f4').demote_to_last
|
|
158
|
+
expect(nf3.variations.last.first.notation).to eq('f4')
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'demote_to_last on the mainline makes it the last variation' do
|
|
162
|
+
branch.next.demote_to_last # Nf3 -> last variation
|
|
163
|
+
# the last stored variation (Nc3) is promoted to mainline
|
|
164
|
+
expect(game.moves[2].notation).to eq('Nc3')
|
|
165
|
+
# old mainline Nf3 + Nf6 is the LAST variation of Nc3
|
|
166
|
+
expect(game.moves[2].variations.last.map(&:notation)).to eq(%w[Nf3 Nf6])
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'every reorder round-trips (variation set stable under re-parse)' do
|
|
170
|
+
find_variation('Nc3').promote_to_main
|
|
171
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
172
|
+
expect(tree_sig(reparsed)).to eq(tree_sig(game))
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
describe 'flatten-on-mutation for same-point nested variations' do
|
|
177
|
+
it 'flattens ( V1 ( V2 ) ) into flat siblings on promote_to_main' do
|
|
178
|
+
# 1. e4 ( e5 ( e6 ) ) ( d5 ) — e5, e6, d5 all branch before e4 (at root).
|
|
179
|
+
g = PGN.parse(%([White "x"]\n\n1. e4 ( e5 ( e6 ) ) ( d5 ) *\n).force_encoding(Encoding::ISO_8859_1)).first
|
|
180
|
+
expect(g.root.children.map(&:notation)).to contain_exactly('e4', 'e5', 'e6', 'd5')
|
|
181
|
+
e6 = g.root.children.find { |n| n.notation == 'e6' }
|
|
182
|
+
e6.promote_to_main
|
|
183
|
+
# e6 is now the mainline; e4, e5, d5 are flat variations of e6
|
|
184
|
+
expect(g.moves.map(&:notation)).to eq(['e6'])
|
|
185
|
+
expect(g.moves[0].variations.map { |v| v.first.notation }).to contain_exactly('e4', 'e5', 'd5')
|
|
186
|
+
reparsed = PGN.parse(g.to_pgn).first
|
|
187
|
+
expect(tree_sig(reparsed)).to eq(tree_sig(g))
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
describe '#delete' do
|
|
192
|
+
let(:branch) { root.next.next }
|
|
193
|
+
let(:nf3) { game.moves[2] }
|
|
194
|
+
|
|
195
|
+
it 'removes a variation' do
|
|
196
|
+
branch.children.find { |n| n.notation == 'f4' }.delete
|
|
197
|
+
expect(nf3.variations.map { |v| v.first.notation }).to contain_exactly('Nc3')
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'removing the mainline continuation promotes the first variation' do
|
|
201
|
+
branch.next.delete # delete Nf3 (mainline)
|
|
202
|
+
# the first stored variation (f4) takes its place
|
|
203
|
+
expect(game.moves[2].notation).to eq('f4')
|
|
204
|
+
expect(game.moves[3].notation).to eq('exf4')
|
|
205
|
+
# the remaining variation (Nc3) is now f4's variation
|
|
206
|
+
expect(game.moves[2].variations.map { |v| v.first.notation }).to contain_exactly('Nc3')
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'removing the mainline continuation with no variations truncates' do
|
|
210
|
+
g = PGN::Game.new(%w[e4 e5 Nf3 Nf6])
|
|
211
|
+
nf3 = g.root.main_line.to_a[2]
|
|
212
|
+
nf3.delete
|
|
213
|
+
expect(g.moves.map(&:notation)).to eq(%w[e4 e5])
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'round-trips after delete' do
|
|
217
|
+
branch.children.find { |n| n.notation == 'f4' }.delete
|
|
218
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
219
|
+
expect(tree_sig(reparsed)).to eq(tree_sig(game))
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PGN::Position, 'attack helpers' do
|
|
6
|
+
describe '#in_check?' do
|
|
7
|
+
it 'is false for the start position' do
|
|
8
|
+
expect(PGN::Position.start.in_check?).to be(false)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'is true when the side to move is checked' do
|
|
12
|
+
# White king on e1, black rook on e2 giving check.
|
|
13
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/8/4r3/4K3 w - - 0 1').to_position
|
|
14
|
+
expect(pos.in_check?).to be(true)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'is false when a piece blocks the check' do
|
|
18
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/8/4p3/4R1K1 w - - 0 1').to_position
|
|
19
|
+
expect(pos.in_check?).to be(false)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'is false for black to move when black is not in check' do
|
|
23
|
+
pos = PGN::FEN.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1').to_position
|
|
24
|
+
expect(pos.in_check?).to be(false)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '#attackers' do
|
|
29
|
+
it 'lists the attacking squares for the opponent color' do
|
|
30
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/8/4r3/4K3 w - - 0 1').to_position
|
|
31
|
+
# White king on e1 is attacked by the black rook on e2.
|
|
32
|
+
expect(pos.attackers('e1')).to include('e2')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'lists pawn attackers' do
|
|
36
|
+
# White pawn d5 attacks e6 and c6; ask for attackers of e6 by white.
|
|
37
|
+
pos = PGN::FEN.new('4k3/8/8/3P4/8/8/8/4K3 w - - 0 1').to_position
|
|
38
|
+
expect(pos.attackers('e6', 'w')).to include('d5')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'lists knight attackers' do
|
|
42
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/2N5/8/4K3 w - - 0 1').to_position
|
|
43
|
+
expect(pos.attackers('e4', 'w')).to include('c3')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'lists bishop/rook/queen attackers' do
|
|
47
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/8/8/Q3K3 w - - 0 1').to_position
|
|
48
|
+
expect(pos.attackers('a8', 'w')).to include('a1')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'returns an empty array when nothing attacks the square' do
|
|
52
|
+
pos = PGN::Position.start
|
|
53
|
+
expect(pos.attackers('e4')).to eq([])
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PGN::Position, '#legal?' do
|
|
6
|
+
skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
|
|
7
|
+
|
|
8
|
+
let(:start) { PGN::Position.start }
|
|
9
|
+
|
|
10
|
+
it 'accepts SAN pawn moves' do
|
|
11
|
+
expect(start.legal?('e4')).to be(true)
|
|
12
|
+
expect(start.legal?('d4')).to be(true)
|
|
13
|
+
expect(start.legal?('e5')).to be(false) # no pawn on e5 can move
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'accepts SAN piece moves' do
|
|
17
|
+
expect(start.legal?('Nf3')).to be(true)
|
|
18
|
+
expect(start.legal?('Nc3')).to be(true)
|
|
19
|
+
expect(start.legal?('Nb1')).to be(false) # no piece can move to b1 from start
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'accepts UCI strings' do
|
|
23
|
+
expect(start.legal?('e2e4')).to be(true)
|
|
24
|
+
expect(start.legal?('e2e5')).to be(false)
|
|
25
|
+
expect(start.legal?('g1f3')).to be(true)
|
|
26
|
+
expect(start.legal?('b1a3')).to be(true) # knight on b1 can reach a3
|
|
27
|
+
expect(start.legal?('b1b3')).to be(false) # knights do not move two squares
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'accepts SAN with check/mate suffixes' do
|
|
31
|
+
# Scholar's mate position before the queen move: 1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6?? 4. Qxf7#
|
|
32
|
+
pos = PGN::FEN.new('r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR w KQkq - 4 4').to_position
|
|
33
|
+
expect(pos.legal?('Qxf7#')).to be(true)
|
|
34
|
+
expect(pos.legal?('Qxf7+')).to be(true)
|
|
35
|
+
expect(pos.legal?('Qxf7')).to be(true)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'accepts redundant disambiguation in SAN' do
|
|
39
|
+
# Two knights (b1 and f1) can reach d2; "Nbd2" and "Nfd2" are both legal,
|
|
40
|
+
# and the unambiguous "Nd2" is rejected as ambiguous.
|
|
41
|
+
pos = PGN::FEN.new('4k3/8/8/8/8/8/8/1N3NK1 w - - 0 1').to_position
|
|
42
|
+
expect(pos.legal?('Nbd2')).to be(true)
|
|
43
|
+
expect(pos.legal?('Nfd2')).to be(true)
|
|
44
|
+
expect(pos.legal?('Nd2')).to be(false) # ambiguous, no disambiguation
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'handles castling SAN and UCI' do
|
|
48
|
+
pos = PGN::FEN.new('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').to_position
|
|
49
|
+
expect(pos.legal?('O-O')).to be(true)
|
|
50
|
+
expect(pos.legal?('O-O-O')).to be(true)
|
|
51
|
+
expect(pos.legal?('e1g1')).to be(true)
|
|
52
|
+
expect(pos.legal?('e1c1')).to be(true)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'handles promotion SAN and UCI' do
|
|
56
|
+
pos = PGN::FEN.new('8/P7/8/8/8/8/8/4k2K w - - 0 1').to_position
|
|
57
|
+
expect(pos.legal?('a8=Q')).to be(true)
|
|
58
|
+
expect(pos.legal?('a8=N')).to be(true)
|
|
59
|
+
expect(pos.legal?('a7a8q')).to be(true)
|
|
60
|
+
expect(pos.legal?('a7a8n')).to be(true)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'handles en passant SAN' do
|
|
64
|
+
pos = PGN::FEN.new('rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3').to_position
|
|
65
|
+
expect(pos.legal?('exd6')).to be(true)
|
|
66
|
+
expect(pos.legal?('e5d6')).to be(true)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'rejects moves that leave the king in check' do
|
|
70
|
+
# White king on e1, rook pinning the knight on e2 to the king along the e-file.
|
|
71
|
+
pos = PGN::FEN.new('4r3/8/8/8/8/8/4N3/4K3 w - - 0 1').to_position
|
|
72
|
+
expect(pos.legal?('Nf4')).to be(false) # knight moves away, king is in check from rook
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'returns false for malformed input' do
|
|
76
|
+
expect(start.legal?('z9z9')).to be(false)
|
|
77
|
+
expect(start.legal?('Qxh8')).to be(false) # no legal queen move from start
|
|
78
|
+
expect(start.legal?('')).to be(false)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'respects the side to move' do
|
|
82
|
+
# Black to move; a white move should be illegal.
|
|
83
|
+
pos = PGN::FEN.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1').to_position
|
|
84
|
+
expect(pos.legal?('e4')).to be(false)
|
|
85
|
+
expect(pos.legal?('e5')).to be(true)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
RSpec.describe PGN::Position, '#legal_moves_san' do
|
|
90
|
+
skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
|
|
91
|
+
|
|
92
|
+
it 'returns 20 sorted SAN moves for the start position' do
|
|
93
|
+
sans = PGN::Position.start.legal_moves_san
|
|
94
|
+
expect(sans.length).to eq(20)
|
|
95
|
+
expect(sans).to eq(sans.sort)
|
|
96
|
+
expect(sans).to include('e4', 'Nf3', 'd4', 'a3')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'includes castling and promotion SAN' do
|
|
100
|
+
pos = PGN::FEN.new('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').to_position
|
|
101
|
+
sans = pos.legal_moves_san
|
|
102
|
+
expect(sans).to include('O-O', 'O-O-O')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'produces SAN whose moves are all legal? and vice versa' do
|
|
106
|
+
pos = PGN::FEN.new('r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR w KQkq - 4 4').to_position
|
|
107
|
+
sans = pos.legal_moves_san
|
|
108
|
+
uci = pos.legal_moves
|
|
109
|
+
expect(sans.length).to eq(uci.length)
|
|
110
|
+
expect(sans.all? { |s| pos.legal?(s) }).to be(true)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'round-trips: every legal UCI maps to a SAN that legal? accepts' do
|
|
114
|
+
pos = PGN::FEN.new('rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3').to_position
|
|
115
|
+
pos.legal_moves.each do |uci|
|
|
116
|
+
from = uci[0, 2]
|
|
117
|
+
to = uci[2, 2]
|
|
118
|
+
promo = uci[5]
|
|
119
|
+
san = PGN::Notation.san(pos, from, to, promo)
|
|
120
|
+
expect(pos.legal?(san)).to be(true), "#{san} (from #{uci}) should be legal"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|