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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +34 -3
  3. data/.github/workflows/native.yml +32 -0
  4. data/.github/workflows/publish.yml +44 -2
  5. data/.github/workflows/release-gems.yml +43 -0
  6. data/.github/workflows/release.yml +52 -5
  7. data/.gitignore +9 -1
  8. data/.rubocop.yml +46 -6
  9. data/CHANGELOG.md +215 -0
  10. data/Gemfile +3 -0
  11. data/NOTICE.md +21 -0
  12. data/README.md +134 -5
  13. data/Rakefile +53 -10
  14. data/TODO.md +44 -62
  15. data/bench/baseline_moves.txt +38 -4
  16. data/bench/baseline_parse.txt +4 -4
  17. data/bench/cross_check.rb +61 -0
  18. data/bench/legal_moves.rb +38 -0
  19. data/bench/perft.rb +32 -0
  20. data/bench/profile_moves.rb +93 -0
  21. data/docs/superpowers/plans/2026-08-13-attack-masks-plan.md +51 -0
  22. data/docs/superpowers/plans/2026-08-13-perf-internals-plan.md +883 -0
  23. data/docs/superpowers/plans/2026-08-13-rust-bitboard-perft-plan.md +2442 -0
  24. data/docs/superpowers/plans/2026-08-14-chessie-migration.md +722 -0
  25. data/docs/superpowers/plans/2026-08-14-rust-integration-plan.md +444 -0
  26. data/docs/superpowers/plans/2026-08-15-game-tree-api-plan.md +1014 -0
  27. data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -0
  28. data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
  29. data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
  30. data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
  31. data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
  32. data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
  33. data/ext/pgn2_native/Cargo.lock +321 -0
  34. data/ext/pgn2_native/Cargo.toml +19 -0
  35. data/ext/pgn2_native/extconf.rb +8 -0
  36. data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
  37. data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
  38. data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
  39. data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
  40. data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
  41. data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
  42. data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
  43. data/lib/pgn/attack.rb +97 -0
  44. data/lib/pgn/bitboard.rb +13 -0
  45. data/lib/pgn/board.rb +64 -0
  46. data/lib/pgn/epd.rb +81 -0
  47. data/lib/pgn/fen.rb +42 -46
  48. data/lib/pgn/game.rb +104 -16
  49. data/lib/pgn/move.rb +20 -16
  50. data/lib/pgn/move_calculator.rb +13 -1
  51. data/lib/pgn/node.rb +372 -0
  52. data/lib/pgn/notation.rb +26 -89
  53. data/lib/pgn/pgn_parser.rb +30 -31
  54. data/lib/pgn/pgn_parser.y +14 -15
  55. data/lib/pgn/position.rb +251 -19
  56. data/lib/pgn/serializer.rb +13 -18
  57. data/lib/pgn/version.rb +1 -1
  58. data/lib/pgn/zobrist.rb +53 -0
  59. data/lib/pgn.rb +5 -0
  60. data/pgn2.gemspec +17 -10
  61. data/spec/bitboard_spec.rb +54 -0
  62. data/spec/board_spec.rb +53 -0
  63. data/spec/castling_normalization_spec.rb +39 -0
  64. data/spec/comment_round_trip_spec.rb +35 -0
  65. data/spec/epd_spec.rb +44 -0
  66. data/spec/fen_spec.rb +71 -65
  67. data/spec/game_history_spec.rb +46 -0
  68. data/spec/game_spec.rb +112 -15
  69. data/spec/lexer_spec.rb +5 -5
  70. data/spec/movetext_clean_spec.rb +44 -0
  71. data/spec/node_spec.rb +240 -0
  72. data/spec/notation_spec.rb +5 -0
  73. data/spec/outcome_spec.rb +93 -0
  74. data/spec/parser_left_recursion_spec.rb +37 -0
  75. data/spec/parser_spec.rb +8 -1
  76. data/spec/position_attack_spec.rb +56 -0
  77. data/spec/position_legal_spec.rb +123 -0
  78. data/spec/position_spec.rb +128 -27
  79. data/spec/serializer_spec.rb +4 -4
  80. data/spec/zobrist_spec.rb +46 -0
  81. 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 cxd4 Bb4 a3 Ba5 Nc3 Qd6 Nb5 Qe7 Ne5 Bxe2 Qxe2 O-O Rac1 Rac8 Bg5 Bb6 Bxf6 gxf6 Nc4 Rfd8 Nxb6 axb6 Rfd1 f5 Qe3 Qf6 d5 Rxd5 Rxd5 exd5 b3 Kh8 Qxb6 Rg8 Qc5 d4 Nd6 f4 Nxb7 Ne5 Qd5 f3 g3 Nd3 Rc7 Re8 Nd6 Re1+ Kh2 Nxf2 Nxf7+ Kg7 Ng5+ Kh6 Rxh7+]
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(m)
38
- c = m.comment.to_s
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 = "<unescaped-comment>" if c.match?(/[\\{}]/)
44
- vars = (m.variations || []).map { |v| v.map { |mm| move_signature(mm) }.join("\x02") }.sort.join("\x03")
45
- "#{m.notation}\x01#{(m.annotation || []).inspect}\x01#{c}\x01#{vars}"
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
- "#{ctx}: notation"
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
- "#{ctx}[#{i}]: comment #{e.comment.inspect} vs #{actual[i].comment.inspect}"
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 { |mm| move_signature(mm) }.join("\x02") }.sort
62
- act_sigs = (actual[i].variations || []).map { |v| v.map { |mm| move_signature(mm) }.join("\x02") }.sort
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| NON_ROUND_TRIP.include?(File.basename(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
- "#{path}##{gi}: result"
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
- "#{path}##{gi}: tag #{k} #{v.inspect} vs #{reparsed_tags[k].inspect}"
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([:lbracket, :tag_name, :string, :rbracket])
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("{outer {inner} tail}")
61
- expect(toks[0].value).to eq("{outer {inner} tail}")
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("1. e4 e5 1-0")
120
- expect(toks[0].offset).to eq(0) # "1."
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
@@ -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
- expect(game.pgn).to eq "[Event \"Event 1\"]\n[FEN \"4brkn/4bp1p/3q2pP/8/2B3N1/1P4N1/2PP3P/1K2Q3 w - - 0 1\"]\n[PlyCount \"7\"]\n\n1. Qxe7 Qxe7 2. Ne4 Bc6 3. Nef6+ Qxf6 4. Nxf6# 1-0"
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