pgn2 2.0.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/release-gems.yml +5 -2
- data/CHANGELOG.md +69 -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/lib/pgn/attack.rb +97 -0
- data/lib/pgn/epd.rb +81 -0
- data/lib/pgn/fen.rb +8 -1
- data/lib/pgn/game.rb +84 -5
- data/lib/pgn/move.rb +4 -4
- data/lib/pgn/node.rb +372 -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 +191 -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 +60 -0
- data/spec/movetext_clean_spec.rb +44 -0
- data/spec/node_spec.rb +240 -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
- metadata +18 -2
|
@@ -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
|
|
@@ -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
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pgn2
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.
|
|
4
|
+
version: 2.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stacey Touset
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-08-
|
|
12
|
+
date: 2026-08-15 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: rb_sys
|
|
@@ -226,12 +226,15 @@ files:
|
|
|
226
226
|
- docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md
|
|
227
227
|
- docs/superpowers/plans/2026-08-14-chessie-migration.md
|
|
228
228
|
- docs/superpowers/plans/2026-08-14-rust-integration-plan.md
|
|
229
|
+
- docs/superpowers/plans/2026-08-15-game-tree-api-plan.md
|
|
230
|
+
- docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md
|
|
229
231
|
- docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md
|
|
230
232
|
- docs/superpowers/specs/2026-08-13-attack-masks-design.md
|
|
231
233
|
- docs/superpowers/specs/2026-08-13-perf-internals-design.md
|
|
232
234
|
- docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md
|
|
233
235
|
- docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md
|
|
234
236
|
- docs/superpowers/specs/2026-08-14-rust-integration-design.md
|
|
237
|
+
- docs/superpowers/specs/2026-08-15-game-tree-api-design.md
|
|
235
238
|
- examples/immortal_game.pgn
|
|
236
239
|
- ext/pgn2_native/Cargo.lock
|
|
237
240
|
- ext/pgn2_native/Cargo.toml
|
|
@@ -244,13 +247,16 @@ files:
|
|
|
244
247
|
- ext/pgn2_native/pgn2_native/Cargo.toml
|
|
245
248
|
- ext/pgn2_native/pgn2_native/src/lib.rs
|
|
246
249
|
- lib/pgn.rb
|
|
250
|
+
- lib/pgn/attack.rb
|
|
247
251
|
- lib/pgn/bitboard.rb
|
|
248
252
|
- lib/pgn/board.rb
|
|
253
|
+
- lib/pgn/epd.rb
|
|
249
254
|
- lib/pgn/fen.rb
|
|
250
255
|
- lib/pgn/game.rb
|
|
251
256
|
- lib/pgn/lexer.rb
|
|
252
257
|
- lib/pgn/move.rb
|
|
253
258
|
- lib/pgn/move_calculator.rb
|
|
259
|
+
- lib/pgn/node.rb
|
|
254
260
|
- lib/pgn/notation.rb
|
|
255
261
|
- lib/pgn/parser.rb
|
|
256
262
|
- lib/pgn/pgn_parser.rb
|
|
@@ -262,13 +268,21 @@ files:
|
|
|
262
268
|
- pgn2.gemspec
|
|
263
269
|
- spec/bitboard_spec.rb
|
|
264
270
|
- spec/board_spec.rb
|
|
271
|
+
- spec/castling_normalization_spec.rb
|
|
272
|
+
- spec/comment_round_trip_spec.rb
|
|
273
|
+
- spec/epd_spec.rb
|
|
265
274
|
- spec/fen_spec.rb
|
|
275
|
+
- spec/game_history_spec.rb
|
|
266
276
|
- spec/game_spec.rb
|
|
267
277
|
- spec/lexer_spec.rb
|
|
268
278
|
- spec/move_calculator_spec.rb
|
|
269
279
|
- spec/move_spec.rb
|
|
280
|
+
- spec/movetext_clean_spec.rb
|
|
281
|
+
- spec/node_spec.rb
|
|
270
282
|
- spec/notation_spec.rb
|
|
283
|
+
- spec/outcome_spec.rb
|
|
271
284
|
- spec/parser_explicit_spec.rb
|
|
285
|
+
- spec/parser_left_recursion_spec.rb
|
|
272
286
|
- spec/parser_spec.rb
|
|
273
287
|
- spec/pgn_files/alternate_castling.pgn
|
|
274
288
|
- spec/pgn_files/annotations.pgn
|
|
@@ -284,6 +298,8 @@ files:
|
|
|
284
298
|
- spec/pgn_files/two_annotations.pgn
|
|
285
299
|
- spec/pgn_files/two_games.pgn
|
|
286
300
|
- spec/pgn_files/variations.pgn
|
|
301
|
+
- spec/position_attack_spec.rb
|
|
302
|
+
- spec/position_legal_spec.rb
|
|
287
303
|
- spec/position_spec.rb
|
|
288
304
|
- spec/serializer_spec.rb
|
|
289
305
|
- spec/spec_helper.rb
|