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,1014 @@
|
|
|
1
|
+
# Game-tree API Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add a navigable, mutable `PGN::Node` tree over the existing `MoveText` structure, with per-node positions and variation management (add/promote/demote/delete), non-breaking.
|
|
6
|
+
|
|
7
|
+
**Architecture:** `MoveText` stays the source of truth (parser builds it, serializer reads it → byte-identical round-trip preserved). `PGN::Node` is a live lazily-built view via `Game#root`; mutations edit `MoveText` arrays in place and normalize the affected branching point to flat sibling storage. `Node#position` is pure-Ruby replay (`Position#move`), no engine dependency.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, RSpec, stdlib only (no new deps).
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-08-15-game-tree-api-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Pure Ruby only — no `PGN::Bitboard::Engine` dependency in the node API.
|
|
16
|
+
- `Game#moves` and `MoveText` keep their existing shapes (backward compat); the 241-example suite stays green.
|
|
17
|
+
- `Game#to_pgn` stays byte-identical for un-mutated games; after a mutation it must re-parse to the mutated structure (idempotent).
|
|
18
|
+
- Castling SAN is canonicalized `0`→`O` exactly as `Game#moves=` / `standardize_castling` does.
|
|
19
|
+
- `PGN::Node` instances go stale after any structural mutation; mutation methods return a fresh `game.root` for re-navigation (no runtime stale-check).
|
|
20
|
+
- Tests run with `bundle _2.7.2_ exec rspec` in the worktree `.worktrees/game-tree-engine-book`.
|
|
21
|
+
|
|
22
|
+
## File Structure
|
|
23
|
+
|
|
24
|
+
- Create `lib/pgn/node.rb` — `PGN::Node` (the whole node abstraction).
|
|
25
|
+
- Modify `lib/pgn.rb` — add `require 'pgn/node'` to the central require list.
|
|
26
|
+
- Modify `lib/pgn/game.rb` — add `Game#root` (one-liner building the root `Node`).
|
|
27
|
+
- Create `spec/node_spec.rb` — node behavior + mutation + round-trip.
|
|
28
|
+
- Modify `spec/game_spec.rb` — add an additive block exercising mutations on fixtures (existing assertions untouched).
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
### Task 1: Node read-only navigation + `Game#root`
|
|
33
|
+
|
|
34
|
+
**Files:**
|
|
35
|
+
- Create: `lib/pgn/node.rb`
|
|
36
|
+
- Modify: `lib/pgn/game.rb` (add `#root`)
|
|
37
|
+
- Modify: `lib/pgn.rb` (add require)
|
|
38
|
+
- Test: `spec/node_spec.rb`
|
|
39
|
+
|
|
40
|
+
**Interfaces:**
|
|
41
|
+
- Produces: `PGN::Node.new(move:, parent:, line:, index:, starting_position:, game:)`;
|
|
42
|
+
`Node#root?`, `#notation`, `#annotation`, `#comment`, `#children`, `#variations`,
|
|
43
|
+
`#next`, `#previous`, `#[]`, `#main_line`; `PGN::Game#root`.
|
|
44
|
+
|
|
45
|
+
- [ ] **Step 1: Write the failing test**
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# spec/node_spec.rb
|
|
49
|
+
require 'spec_helper'
|
|
50
|
+
|
|
51
|
+
describe PGN::Node do
|
|
52
|
+
let(:game) { PGN.parse(File.read(File.expand_path('spec/pgn_files/variations.pgn', __dir__))).first }
|
|
53
|
+
let(:root) { game.root }
|
|
54
|
+
|
|
55
|
+
it 'root is a root node with no move' do
|
|
56
|
+
expect(root).to be_root
|
|
57
|
+
expect(root.move).to be_nil
|
|
58
|
+
expect(root.notation).to be_nil
|
|
59
|
+
expect(root.previous).to be_nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'main_line matches game.moves notations (root excluded)' do
|
|
63
|
+
expect(root.main_line.map(&:notation)).to eq(game.moves.map(&:notation))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'main_line is an Enumerator without a block' do
|
|
67
|
+
expect(root.main_line).to be_an(Enumerator)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'children of the position after 1.e4 e5 are Nf3 (main), Nc3, f4' do
|
|
71
|
+
node = root.next.next # after e4 (white), after e5 (black) => position before Nf3
|
|
72
|
+
expect(node.children.map(&:notation)).to eq(%w[Nf3 Nc3 f4])
|
|
73
|
+
expect(node.variations.map(&:notation)).to eq(%w[Nc3 f4])
|
|
74
|
+
expect(node.next.notation).to eq('Nf3')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'nested variation f5 is a sibling of d5 under the Nc3 node' do
|
|
78
|
+
nc3_node = root.next.next.variations.first # the Nc3 variation first move
|
|
79
|
+
expect(nc3_node.notation).to eq('Nc3')
|
|
80
|
+
expect(nc3_node.children.map(&:notation)).to eq(%w[d5 f5])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'previous walks back to the root' do
|
|
84
|
+
node = root.next.next.next # Nf3 node
|
|
85
|
+
expect(node.previous.notation).to eq('e5')
|
|
86
|
+
expect(node.previous.previous.notation).to eq('e4')
|
|
87
|
+
expect(node.previous.previous.previous).to be_root
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
93
|
+
|
|
94
|
+
Run: `bundle _2.7.2_ exec rspec spec/node_spec.rb`
|
|
95
|
+
Expected: FAIL — `undefined method 'root' for PGN::Game` / `uninitialized constant PGN::Node`.
|
|
96
|
+
|
|
97
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# lib/pgn/node.rb
|
|
101
|
+
# frozen_string_literal: true
|
|
102
|
+
|
|
103
|
+
module PGN
|
|
104
|
+
# {PGN::Node} is a live, lazily-built view over the {PGN::MoveText} tree.
|
|
105
|
+
# A node represents the position reached by playing +move+ from its
|
|
106
|
+
# +parent+'s position (the root has no move and is the game's starting
|
|
107
|
+
# position). Mutations edit the underlying +MoveText+ arrays in place;
|
|
108
|
+
# after any structural mutation, call +PGN::Game#root+ again for a fresh
|
|
109
|
+
# tree.
|
|
110
|
+
class Node
|
|
111
|
+
attr_reader :move, :parent, :line, :index
|
|
112
|
+
|
|
113
|
+
# @param move [PGN::MoveText, nil] the move played to reach this node
|
|
114
|
+
# @param parent [PGN::Node, nil] the parent node (nil for the root)
|
|
115
|
+
# @param line [Array<PGN::MoveText>] the line this node's move lives in
|
|
116
|
+
# @param index [Integer] index of this node's move within +line+
|
|
117
|
+
# @param starting_position [PGN::Position] the root's position
|
|
118
|
+
# @param game [PGN::Game] back-reference so mutations can return a fresh root
|
|
119
|
+
def initialize(move:, parent:, line:, index:, starting_position: nil, game: nil)
|
|
120
|
+
@move = move
|
|
121
|
+
@parent = parent
|
|
122
|
+
@line = line
|
|
123
|
+
@index = index
|
|
124
|
+
@starting_position = starting_position
|
|
125
|
+
@game = game
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def root?
|
|
129
|
+
@move.nil?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def notation
|
|
133
|
+
@move&.notation
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def annotation
|
|
137
|
+
@move&.annotation
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def comment
|
|
141
|
+
@move&.comment
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# All moves playable from this node's position: the continuation move
|
|
145
|
+
# (the next MoveText in this node's line) plus every variation first-move
|
|
146
|
+
# branching at that same position, recursively through nested brackets.
|
|
147
|
+
# The first child is the mainline continuation; the rest are variations.
|
|
148
|
+
def children
|
|
149
|
+
@children ||= begin
|
|
150
|
+
cont = continuation_movetext
|
|
151
|
+
list = []
|
|
152
|
+
collect_first_moves(cont, @line, @index + 1) do |mt, l, idx|
|
|
153
|
+
list << Node.new(move: mt, parent: self, line: l, index: idx, game: @game)
|
|
154
|
+
end
|
|
155
|
+
list
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def variations
|
|
160
|
+
children[1..]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def next
|
|
164
|
+
children.first
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def previous
|
|
168
|
+
@parent
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def [](i)
|
|
172
|
+
children[i]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Yields each mainline node from +self.next+ onward (the root is
|
|
176
|
+
# excluded), matching +game.moves+ and +game.positions[1..]+.
|
|
177
|
+
def main_line
|
|
178
|
+
return enum_for(:main_line) unless block_given?
|
|
179
|
+
|
|
180
|
+
node = next
|
|
181
|
+
while node
|
|
182
|
+
yield node
|
|
183
|
+
node = node.next
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
# The MoveText played from this node's position in the current line:
|
|
190
|
+
# the next entry in +line+ (nil at a terminal position). For the root,
|
|
191
|
+
# +index+ is -1, so this is +line[0]+ (the first mainline move).
|
|
192
|
+
def continuation_movetext
|
|
193
|
+
@line && @line[@index + 1]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Yield every first-move branching at the position before +m+ (i.e. at
|
|
197
|
+
# this node's position): +m+ itself (in +l+ at +idx+), plus the first move
|
|
198
|
+
# of each of +m+'s variations, plus — recursively — the first moves of
|
|
199
|
+
# any variations nested on those first moves (they branch at the same
|
|
200
|
+
# point, since a variation branches before the move it attaches to).
|
|
201
|
+
def collect_first_moves(m, l, idx)
|
|
202
|
+
return if m.nil?
|
|
203
|
+
|
|
204
|
+
yield m, l, idx
|
|
205
|
+
(m.variations || []).each do |v|
|
|
206
|
+
next unless v && v[0]
|
|
207
|
+
|
|
208
|
+
collect_first_moves(v[0], v, 0) { |*a| yield(*a) }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
# lib/pgn.rb — add the require (insert after `require 'pgn/game'`):
|
|
217
|
+
require 'pgn/game'
|
|
218
|
+
require 'pgn/node' # <-- add
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
# lib/pgn/game.rb — add inside the Game class (e.g. right after `attr_reader :moves`):
|
|
223
|
+
# Build a fresh, navigable {PGN::Node} tree over the mainline. The tree is a
|
|
224
|
+
# live view of the underlying +MoveText+ structure; mutate it through the
|
|
225
|
+
# node API, then call +#root+ again for a fresh tree.
|
|
226
|
+
def root
|
|
227
|
+
PGN::Node.new(
|
|
228
|
+
move: nil, parent: nil, line: @moves, index: -1,
|
|
229
|
+
starting_position: starting_position, game: self
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
235
|
+
|
|
236
|
+
Run: `bundle _2.7.2_ exec rspec spec/node_spec.rb`
|
|
237
|
+
Expected: PASS (6 examples).
|
|
238
|
+
|
|
239
|
+
- [ ] **Step 5: Run the full suite to confirm no regressions**
|
|
240
|
+
|
|
241
|
+
Run: `bundle _2.7.2_ exec rspec`
|
|
242
|
+
Expected: PASS — 247 examples (241 + 6), 0 failures.
|
|
243
|
+
|
|
244
|
+
- [ ] **Step 6: Commit**
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
git add lib/pgn/node.rb lib/pgn.rb lib/pgn/game.rb spec/node_spec.rb
|
|
248
|
+
git commit -m "feat(node): add PGN::Node read-only navigation + Game#root"
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
### Task 2: `Node#position` (lazy, cached, pure-Ruby)
|
|
254
|
+
|
|
255
|
+
**Files:**
|
|
256
|
+
- Modify: `lib/pgn/node.rb` (add `#position`)
|
|
257
|
+
- Test: `spec/node_spec.rb`
|
|
258
|
+
|
|
259
|
+
**Interfaces:**
|
|
260
|
+
- Produces: `Node#position` → `PGN::Position` (cached). Root's position is
|
|
261
|
+
`game.starting_position`; non-root is `parent.position.move(move.notation)`.
|
|
262
|
+
|
|
263
|
+
- [ ] **Step 1: Write the failing test**
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
# append to spec/node_spec.rb describe block
|
|
267
|
+
describe '#position' do
|
|
268
|
+
it 'root position is the starting position' do
|
|
269
|
+
expect(root.position.to_fen.to_s).to eq(PGN::Position.start.to_fen.to_s)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it 'main_line positions match game.positions[1..]' do
|
|
273
|
+
expect(root.main_line.map { |n| n.position.to_fen.to_s })
|
|
274
|
+
.to eq(game.positions[1..].map { |p| p.to_fen.to_s })
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
it 'a variation node position is replayed from the root' do
|
|
278
|
+
# After 1.e4 e5 2.Nc3 (the Nc3 variation first move)
|
|
279
|
+
nc3_node = root.next.next.variations.first
|
|
280
|
+
expected = PGN::Position.start.move('e4').move('e5').move('Nc3')
|
|
281
|
+
expect(nc3_node.position.to_fen.to_s).to eq(expected.to_fen.to_s)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
it 'is cached (returns the same object on second call)' do
|
|
285
|
+
node = root.next
|
|
286
|
+
expect(node.position).to equal(node.position)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
292
|
+
|
|
293
|
+
Run: `bundle _2.7.2_ exec rspec spec/node_spec.rb`
|
|
294
|
+
Expected: FAIL — `undefined method 'position'`.
|
|
295
|
+
|
|
296
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
# inside PGN::Node, above `private` (or as a public method):
|
|
300
|
+
# The position reached at this node: the starting position for the root,
|
|
301
|
+
# otherwise +parent.position+ with +move.notation+ applied. Pure Ruby
|
|
302
|
+
# (no native engine); raises on an illegal SAN exactly like
|
|
303
|
+
# +Game#positions+. Cached on the node.
|
|
304
|
+
def position
|
|
305
|
+
return @position if defined?(@position)
|
|
306
|
+
|
|
307
|
+
@position = if root?
|
|
308
|
+
@starting_position
|
|
309
|
+
else
|
|
310
|
+
@parent.position.then { |p| p.move(@move.notation) }
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
316
|
+
|
|
317
|
+
Run: `bundle _2.7.2_ exec rspec spec/node_spec.rb`
|
|
318
|
+
Expected: PASS (10 examples).
|
|
319
|
+
|
|
320
|
+
- [ ] **Step 5: Commit**
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
git add lib/pgn/node.rb spec/node_spec.rb
|
|
324
|
+
git commit -m "feat(node): add lazy cached Node#position (pure-Ruby replay)"
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
### Task 3: `add_variation` / `add_main_variation`
|
|
330
|
+
|
|
331
|
+
**Files:**
|
|
332
|
+
- Modify: `lib/pgn/node.rb` (add mutation helpers)
|
|
333
|
+
- Test: `spec/node_spec.rb`
|
|
334
|
+
|
|
335
|
+
**Interfaces:**
|
|
336
|
+
- Produces: `Node#add_variation(move_or_moves)` and
|
|
337
|
+
`Node#add_main_variation(move_or_moves)`, each returning a fresh
|
|
338
|
+
`game.root`. `add_variation` raises `ArgumentError` at a terminal node.
|
|
339
|
+
|
|
340
|
+
- [ ] **Step 1: Write the failing test**
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
# append to spec/node_spec.rb describe block
|
|
344
|
+
describe '#add_variation' do
|
|
345
|
+
it 'appends a single-SAN variation branching before the next move' do
|
|
346
|
+
node = root.next.next # position before Nf3
|
|
347
|
+
node.add_variation('Nc6')
|
|
348
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
349
|
+
# Nf3 now has variations Nc3, f4, and the new Nc6
|
|
350
|
+
nf3 = reparsed.moves[2]
|
|
351
|
+
expect(nf3.variations.map { |v| v.map(&:notation) })
|
|
352
|
+
.to include(%w[Nc3 d5 exd5], %w[f4 exf4], ['Nc6'])
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it 'appends a multi-move variation line' do
|
|
356
|
+
node = root.next.next
|
|
357
|
+
node.add_variation(%w[Nc6 Bc4])
|
|
358
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
359
|
+
expect(reparsed.moves[2].variations.map { |v| v.map(&:notation) })
|
|
360
|
+
.to include(%w[Nc6 Bc4])
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
it 'normalizes castling 0 -> O' do
|
|
364
|
+
game = PGN::Game.new(%w[e4 e5 Nf3])
|
|
365
|
+
root = game.root
|
|
366
|
+
root.next.next.add_variation('0-0')
|
|
367
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
368
|
+
expect(reparsed.moves[2].variations.map { |v| v.map(&:notation) }).to include(['O-O'])
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
it 'round-trips after add_variation' do
|
|
372
|
+
root.next.next.add_variation(%w[Nc6 Bc4])
|
|
373
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
374
|
+
expect(PGN.parse(reparsed.to_pgn).first.to_pgn).to eq(reparsed.to_pgn)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
describe '#add_variation at a terminal node' do
|
|
379
|
+
it 'raises ArgumentError' do
|
|
380
|
+
game = PGN::Game.new(%w[e4 e5])
|
|
381
|
+
last = game.root.main_line.last
|
|
382
|
+
expect { last.add_variation('Nf6') }.to raise_error(ArgumentError)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
describe '#add_main_variation' do
|
|
387
|
+
it 'extends the line at a terminal node' do
|
|
388
|
+
game = PGN::Game.new(%w[e4 e5])
|
|
389
|
+
last = game.root.main_line.last
|
|
390
|
+
last.add_main_variation('Nf3')
|
|
391
|
+
expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nf3])
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
it 'inserts a new mainline move and demotes the old continuation' do
|
|
395
|
+
node = root.next.next # position before Nf3
|
|
396
|
+
node.add_main_variation('Nc6')
|
|
397
|
+
expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nc6 Nf6])
|
|
398
|
+
# the old continuation Nf3 is now a variation of Nc6
|
|
399
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
400
|
+
expect(reparsed.moves[2].variations.map { |v| v.map(&:notation) })
|
|
401
|
+
.to include(['Nf3'])
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
407
|
+
|
|
408
|
+
Run: `bundle _2.7.2_ exec rspec spec/node_spec.rb`
|
|
409
|
+
Expected: FAIL — `undefined method 'add_variation'`.
|
|
410
|
+
|
|
411
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
# inside PGN::Node (public mutation methods):
|
|
415
|
+
# Append a new variation line (a single SAN String or an Array<String>)
|
|
416
|
+
# branching before this node's next mainline move. Returns a fresh
|
|
417
|
+
# +game.root+. Raises +ArgumentError+ at a terminal node (a variation
|
|
418
|
+
# must branch before an existing move; use +add_main_variation+ to
|
|
419
|
+
# extend the line).
|
|
420
|
+
def add_variation(move_or_moves)
|
|
421
|
+
cont = continuation_movetext
|
|
422
|
+
raise ArgumentError, 'cannot add a variation at a terminal node' if cont.nil?
|
|
423
|
+
|
|
424
|
+
normalize_branch_point(cont)
|
|
425
|
+
cont.variations << build_movetexts(move_or_moves)
|
|
426
|
+
@game.root
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Make the given moves the new mainline continuation from this node's
|
|
430
|
+
# position. At a terminal node this extends the line; otherwise the old
|
|
431
|
+
# continuation becomes a variation of the new first move. Returns a
|
|
432
|
+
# fresh +game.root+.
|
|
433
|
+
def add_main_variation(move_or_moves)
|
|
434
|
+
new_line = build_movetexts(move_or_moves)
|
|
435
|
+
cont = continuation_movetext
|
|
436
|
+
|
|
437
|
+
if cont.nil?
|
|
438
|
+
@line.push(*new_line)
|
|
439
|
+
else
|
|
440
|
+
normalize_branch_point(cont)
|
|
441
|
+
vars = cont.variations
|
|
442
|
+
pos = @index + 1
|
|
443
|
+
old_tail = @line[(pos + 1)..] || []
|
|
444
|
+
n0 = new_line[0]
|
|
445
|
+
@line[pos] = n0
|
|
446
|
+
@line[(pos + 1)..] = (new_line[1..] || [])
|
|
447
|
+
cont.variations = []
|
|
448
|
+
n0.variations = [[cont, *old_tail], *vars]
|
|
449
|
+
end
|
|
450
|
+
@game.root
|
|
451
|
+
end
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
```ruby
|
|
455
|
+
# inside PGN::Node `private`:
|
|
456
|
+
# Build an Array<PGN::MoveText> from a single SAN String or an Array of
|
|
457
|
+
# SANs, applying the same castling 0->O normalization as Game#moves=.
|
|
458
|
+
def build_movetexts(move_or_moves)
|
|
459
|
+
sans = move_or_moves.is_a?(String) ? [move_or_moves] : move_or_moves
|
|
460
|
+
sans.map { |s| PGN::MoveText.new(normalize_castling(s)) }
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def normalize_castling(san)
|
|
464
|
+
san.include?('0') ? san.gsub('0', 'O') : san
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Hoist every variation first-move branching at the position before
|
|
468
|
+
# +cont+ (recursively through nested brackets) into a single flat
|
|
469
|
+
# +cont.variations+ array, clearing the first-moves' at-point variations
|
|
470
|
+
# (they are now flat siblings). Internal structure of each variation
|
|
471
|
+
# line (tail moves and their deeper variations) is untouched. After
|
|
472
|
+
# this, +cont.variations+ is a flat Array<Array<MoveText>> in the same
|
|
473
|
+
# order +children+ yields.
|
|
474
|
+
def normalize_branch_point(cont)
|
|
475
|
+
return if cont.nil?
|
|
476
|
+
|
|
477
|
+
lines = []
|
|
478
|
+
collect_variation_lines(cont) { |v| lines << v }
|
|
479
|
+
lines.each { |v| v[0].variations = [] }
|
|
480
|
+
cont.variations = lines
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Yield each variation line branching at the position before +m+ (in
|
|
484
|
+
# DFS order): +m+'s direct variations, plus — recursively — the
|
|
485
|
+
# variations nested on each variation's first move (they branch at the
|
|
486
|
+
# same point).
|
|
487
|
+
def collect_variation_lines(m)
|
|
488
|
+
(m.variations || []).each do |v|
|
|
489
|
+
next unless v && v[0]
|
|
490
|
+
|
|
491
|
+
yield v
|
|
492
|
+
collect_variation_lines(v[0]) { |x| yield x }
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
498
|
+
|
|
499
|
+
Run: `bundle _2.7.2_ exec rspec spec/node_spec.rb`
|
|
500
|
+
Expected: PASS.
|
|
501
|
+
|
|
502
|
+
- [ ] **Step 5: Commit**
|
|
503
|
+
|
|
504
|
+
```bash
|
|
505
|
+
git add lib/pgn/node.rb spec/node_spec.rb
|
|
506
|
+
git commit -m "feat(node): add_variation / add_main_variation with flatten-on-mutate"
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
---
|
|
510
|
+
|
|
511
|
+
### Task 4: `promote` / `demote` / `promote_to_main` / `demote_to_last`
|
|
512
|
+
|
|
513
|
+
**Files:**
|
|
514
|
+
- Modify: `lib/pgn/node.rb`
|
|
515
|
+
- Test: `spec/node_spec.rb`
|
|
516
|
+
|
|
517
|
+
**Interfaces:**
|
|
518
|
+
- Produces: `Node#promote`, `#demote`, `#promote_to_main`, `#demote_to_last`,
|
|
519
|
+
each returning a fresh `game.root`. `demote` at index 0 is the inverse swap
|
|
520
|
+
(variation #1 becomes mainline, old mainline becomes variation #1).
|
|
521
|
+
|
|
522
|
+
- [ ] **Step 1: Write the failing test**
|
|
523
|
+
|
|
524
|
+
```ruby
|
|
525
|
+
# append to spec/node_spec.rb describe block
|
|
526
|
+
describe 'variation reordering' do
|
|
527
|
+
# Before: after 1.e4 e5, children = [Nf3(main), Nc3, f4]
|
|
528
|
+
# Nf3.variations = [[Nc3,d5,exd5],[f4,exf4]]
|
|
529
|
+
let(:branch) { root.next.next }
|
|
530
|
+
|
|
531
|
+
it 'promote moves a variation one slot toward mainline' do
|
|
532
|
+
f4 = branch.variations.last # index 2
|
|
533
|
+
f4.promote
|
|
534
|
+
expect(game.moves[2].notation).to eq('Nf3')
|
|
535
|
+
nf3 = game.moves[2]
|
|
536
|
+
# f4 now precedes Nc3
|
|
537
|
+
expect(nf3.variations.map { |v| v.first.notation }).to eq(%w[f4 Nc3])
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
it 'promote is a no-op for the first variation' do
|
|
541
|
+
nc3 = branch.variations.first
|
|
542
|
+
nc3.promote
|
|
543
|
+
nf3 = game.moves[2]
|
|
544
|
+
expect(nf3.variations.map { |v| v.first.notation }).to eq(%w[Nc3 f4])
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
it 'demote moves a variation one slot toward the end' do
|
|
548
|
+
nc3 = branch.variations.first
|
|
549
|
+
nc3.demote
|
|
550
|
+
nf3 = game.moves[2]
|
|
551
|
+
expect(nf3.variations.map { |v| v.first.notation }).to eq(%w[f4 Nc3])
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
it 'demote at index 0 (mainline) is the inverse swap' do
|
|
555
|
+
mainline = branch.next # Nf3, index 0
|
|
556
|
+
mainline.demote
|
|
557
|
+
expect(game.moves[2].notation).to eq('Nc3') # Nc3 is new mainline
|
|
558
|
+
nc3 = game.moves[2]
|
|
559
|
+
# old mainline Nf3 (with its tail Nf6) is now the first variation of Nc3
|
|
560
|
+
expect(nc3.variations.first.map(&:notation)).to eq(%w[Nf3 Nf6])
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
it 'promote_to_main makes a variation the new mainline' do
|
|
564
|
+
f4 = branch.variations.last
|
|
565
|
+
f4.promote_to_main
|
|
566
|
+
expect(game.moves[2].notation).to eq('f4')
|
|
567
|
+
expect(game.moves[3].notation).to eq('exf4')
|
|
568
|
+
# old mainline Nf3 + Nf6 is now a variation of f4
|
|
569
|
+
f4m = game.moves[2]
|
|
570
|
+
expect(f4m.variations.map { |v| v.map(&:notation) }).to include(%w[Nf3 Nf6])
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
it 'demote_to_last moves a variation to the end' do
|
|
574
|
+
nc3 = branch.variations.first
|
|
575
|
+
nc3.demote_to_last
|
|
576
|
+
nf3 = game.moves[2]
|
|
577
|
+
expect(nf3.variations.map { |v| v.first.notation }).to eq(%w[f4 Nc3])
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
it 'demote_to_last on the mainline makes it the last variation' do
|
|
581
|
+
mainline = branch.next # Nf3
|
|
582
|
+
mainline.demote_to_last
|
|
583
|
+
expect(game.moves[2].notation).to eq('Nc3') # first variation promoted
|
|
584
|
+
nc3 = game.moves[2]
|
|
585
|
+
# old mainline Nf3 is the LAST variation of Nc3
|
|
586
|
+
expect(nc3.variations.last.map(&:notation)).to eq(%w[Nf3 Nf6])
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
it 'every reorder round-trips (re-parse == self)' do
|
|
590
|
+
branch.variations.last.promote_to_main
|
|
591
|
+
once = game.to_pgn
|
|
592
|
+
expect(PGN.parse(once).first.to_pgn).to eq(once)
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
describe 'flatten-on-mutation for same-point nested variations' do
|
|
597
|
+
it 'flattens ( V1 ( V2 ) ) into ( V1 ) ( V2 ) on promote' do
|
|
598
|
+
# Construct: 1.e4 e5 ( e5? no — build ( Nf3 ( Nc3 ) ) at the root branch )
|
|
599
|
+
game = PGN.parse("[White \"x\"]\n\n1. e4 ( e5 ( e6 ) ) ( d5 ) *\n").first
|
|
600
|
+
branch = game.root # position before e4
|
|
601
|
+
node = branch.next.variations.first # the e5 variation
|
|
602
|
+
# e5's nested variation e6 is a sibling of e5; promote e6 past e5
|
|
603
|
+
e6 = branch.next.children.find { |n| n.notation == 'e6' }
|
|
604
|
+
e6.promote_to_main
|
|
605
|
+
reparsed = PGN.parse(game.to_pgn).first
|
|
606
|
+
# e4 now has flat variations [e5, e6, d5]-ish; assert idempotent round trip
|
|
607
|
+
expect(PGN.parse(reparsed.to_pgn).first.to_pgn).to eq(reparsed.to_pgn)
|
|
608
|
+
e4 = reparsed.moves[0]
|
|
609
|
+
expect(e4.variations.map { |v| v.first.notation }).to contain_exactly('e6', 'e5', 'd5')
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
615
|
+
|
|
616
|
+
Run: `bundle _2.7.2_ exec rspec spec/node_spec.rb`
|
|
617
|
+
Expected: FAIL — `undefined method 'promote'`.
|
|
618
|
+
|
|
619
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
620
|
+
|
|
621
|
+
```ruby
|
|
622
|
+
# inside PGN::Node (public):
|
|
623
|
+
# Move this node one slot toward the mainline among its siblings.
|
|
624
|
+
# No-op for the mainline (index 0) or the first variation (index 1).
|
|
625
|
+
# Returns a fresh +game.root+.
|
|
626
|
+
def promote
|
|
627
|
+
return @game.root if root?
|
|
628
|
+
|
|
629
|
+
i = sibling_index
|
|
630
|
+
return @game.root if i.nil? || i <= 1
|
|
631
|
+
|
|
632
|
+
cont = parent_continuation
|
|
633
|
+
normalize_branch_point(cont)
|
|
634
|
+
vars = cont.variations
|
|
635
|
+
vars[i - 1], vars[i - 2] = vars[i - 2], vars[i - 1]
|
|
636
|
+
@game.root
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# Move this node one slot toward the end among its siblings. At index 0
|
|
640
|
+
# (the mainline) this is the inverse swap: variation #1 becomes the new
|
|
641
|
+
# mainline and the old mainline becomes variation #1. No-op at the last
|
|
642
|
+
# index. Returns a fresh +game.root+.
|
|
643
|
+
def demote
|
|
644
|
+
return @game.root if root?
|
|
645
|
+
|
|
646
|
+
i = sibling_index
|
|
647
|
+
sibs = @parent.children
|
|
648
|
+
return @game.root if i.nil? || i == sibs.size - 1
|
|
649
|
+
|
|
650
|
+
cont = parent_continuation
|
|
651
|
+
normalize_branch_point(cont)
|
|
652
|
+
vars = cont.variations
|
|
653
|
+
if i == 0
|
|
654
|
+
v1 = vars.shift
|
|
655
|
+
make_mainline(cont, v1, vars, old_main_position: :first)
|
|
656
|
+
else
|
|
657
|
+
vars[i - 1], vars[i] = vars[i], vars[i - 1]
|
|
658
|
+
end
|
|
659
|
+
@game.root
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Make this node the mainline at its branching point (the old mainline
|
|
663
|
+
# becomes variation #1). No-op if already the mainline. Returns a fresh
|
|
664
|
+
# +game.root+.
|
|
665
|
+
def promote_to_main
|
|
666
|
+
return @game.root if root?
|
|
667
|
+
|
|
668
|
+
i = sibling_index
|
|
669
|
+
return @game.root if i.nil? || i == 0
|
|
670
|
+
|
|
671
|
+
cont = parent_continuation
|
|
672
|
+
normalize_branch_point(cont)
|
|
673
|
+
vars = cont.variations
|
|
674
|
+
vk = vars.delete_at(i - 1)
|
|
675
|
+
make_mainline(cont, vk, vars, old_main_position: :first)
|
|
676
|
+
@game.root
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Move this node to the last position among its siblings. At index 0
|
|
680
|
+
# the last variation becomes the new mainline and the old mainline
|
|
681
|
+
# becomes the last variation. Returns a fresh +game.root+.
|
|
682
|
+
def demote_to_last
|
|
683
|
+
return @game.root if root?
|
|
684
|
+
|
|
685
|
+
i = sibling_index
|
|
686
|
+
return @game.root if i.nil?
|
|
687
|
+
|
|
688
|
+
cont = parent_continuation
|
|
689
|
+
normalize_branch_point(cont)
|
|
690
|
+
vars = cont.variations
|
|
691
|
+
if i == 0
|
|
692
|
+
return @game.root if vars.empty?
|
|
693
|
+
|
|
694
|
+
last = vars.pop
|
|
695
|
+
make_mainline(cont, last, vars, old_main_position: :last)
|
|
696
|
+
else
|
|
697
|
+
el = vars.delete_at(i - 1)
|
|
698
|
+
vars.push(el)
|
|
699
|
+
end
|
|
700
|
+
@game.root
|
|
701
|
+
end
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
```ruby
|
|
705
|
+
# inside PGN::Node `private`:
|
|
706
|
+
# Index of this node among its parent's children, or nil if root.
|
|
707
|
+
def sibling_index
|
|
708
|
+
@parent.children.index(self)
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# The MoveText that is the mainline continuation at the PARENT's
|
|
712
|
+
# position (the move this node is an alternative to, or this node
|
|
713
|
+
# itself if it is the continuation).
|
|
714
|
+
def parent_continuation
|
|
715
|
+
@parent.line[@parent.index + 1]
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Make +vk+ (= [vk0, *tail]) the new mainline continuation at the
|
|
719
|
+
# parent's position, replacing +cont+. The old mainline (+cont+ and its
|
|
720
|
+
# tail) becomes a variation appended to +others+ either before
|
|
721
|
+
# (+old_main_position == :first+) or after (+:last+). +cont.variations+
|
|
722
|
+
# is cleared (its at-point variations are now +vk0+'s siblings).
|
|
723
|
+
def make_mainline(cont, vk, others, old_main_position:)
|
|
724
|
+
line = @parent.line
|
|
725
|
+
pos = @parent.index + 1
|
|
726
|
+
old_tail = line[(pos + 1)..] || []
|
|
727
|
+
vk0 = vk[0]
|
|
728
|
+
line[pos] = vk0
|
|
729
|
+
line[(pos + 1)..] = (vk[1..] || [])
|
|
730
|
+
cont.variations = []
|
|
731
|
+
old_var = [cont, *old_tail]
|
|
732
|
+
vk0.variations =
|
|
733
|
+
old_main_position == :first ? [old_var, *others] : [*others, old_var]
|
|
734
|
+
end
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
738
|
+
|
|
739
|
+
Run: `bundle _2.7.2_ exec rspec spec/node_spec.rb`
|
|
740
|
+
Expected: PASS. (If the nested-flatten test's fixture parse differs, adjust the
|
|
741
|
+
fixture string until `e4.variations` yields the three first-moves; the assertion
|
|
742
|
+
is `contain_exactly` to avoid order sensitivity.)
|
|
743
|
+
|
|
744
|
+
- [ ] **Step 5: Commit**
|
|
745
|
+
|
|
746
|
+
```bash
|
|
747
|
+
git add lib/pgn/node.rb spec/node_spec.rb
|
|
748
|
+
git commit -m "feat(node): promote / demote / promote_to_main / demote_to_last"
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
---
|
|
752
|
+
|
|
753
|
+
### Task 5: `delete`
|
|
754
|
+
|
|
755
|
+
**Files:**
|
|
756
|
+
- Modify: `lib/pgn/node.rb`
|
|
757
|
+
- Test: `spec/node_spec.rb`
|
|
758
|
+
|
|
759
|
+
**Interfaces:**
|
|
760
|
+
- Produces: `Node#delete` → fresh `game.root`. Removing the mainline
|
|
761
|
+
continuation promotes the first variation (or truncates the line if none).
|
|
762
|
+
|
|
763
|
+
- [ ] **Step 1: Write the failing test**
|
|
764
|
+
|
|
765
|
+
```ruby
|
|
766
|
+
# append to spec/node_spec.rb describe block
|
|
767
|
+
describe '#delete' do
|
|
768
|
+
it 'removes a variation' do
|
|
769
|
+
branch = root.next.next
|
|
770
|
+
f4 = branch.variations.last
|
|
771
|
+
f4.delete
|
|
772
|
+
nf3 = game.moves[2]
|
|
773
|
+
expect(nf3.variations.map { |v| v.first.notation }).to eq(%w[Nc3])
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
it 'removing the mainline continuation promotes the first variation' do
|
|
777
|
+
branch = root.next.next
|
|
778
|
+
branch.next.delete # delete Nf3 (mainline)
|
|
779
|
+
expect(game.moves[2].notation).to eq('Nc3')
|
|
780
|
+
nc3 = game.moves[2]
|
|
781
|
+
# remaining variation f4 is now Nc3's variation
|
|
782
|
+
expect(nc3.variations.map { |v| v.first.notation }).to eq(%w[f4])
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
it 'removing the mainline continuation with no variations truncates' do
|
|
786
|
+
game = PGN::Game.new(%w[e4 e5 Nf3 Nf6])
|
|
787
|
+
node = game.root.main_line.last # Nf6 node's parent? -> use the Nf3 node
|
|
788
|
+
nf3 = game.root.main_line.to_a[2]
|
|
789
|
+
nf3.delete
|
|
790
|
+
expect(game.moves.map(&:notation)).to eq(%w[e4 e5])
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
it 'round-trips after delete' do
|
|
794
|
+
root.next.next.variations.last.delete
|
|
795
|
+
once = game.to_pgn
|
|
796
|
+
expect(PGN.parse(once).first.to_pgn).to eq(once)
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
802
|
+
|
|
803
|
+
Run: `bundle _2.7.2_ exec rspec spec/node_spec.rb`
|
|
804
|
+
Expected: FAIL — `undefined method 'delete'`.
|
|
805
|
+
|
|
806
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
807
|
+
|
|
808
|
+
```ruby
|
|
809
|
+
# inside PGN::Node (public):
|
|
810
|
+
# Remove this node and its subtree. If it is the mainline continuation,
|
|
811
|
+
# the first remaining variation (if any) takes its place; otherwise the
|
|
812
|
+
# line is truncated at this point. Returns a fresh +game.root+.
|
|
813
|
+
def delete
|
|
814
|
+
return @game.root if root?
|
|
815
|
+
|
|
816
|
+
i = sibling_index
|
|
817
|
+
return @game.root if i.nil?
|
|
818
|
+
|
|
819
|
+
cont = parent_continuation
|
|
820
|
+
normalize_branch_point(cont)
|
|
821
|
+
vars = cont.variations
|
|
822
|
+
if i == 0
|
|
823
|
+
line = @parent.line
|
|
824
|
+
pos = @parent.index + 1
|
|
825
|
+
if vars.empty?
|
|
826
|
+
line[pos..] = []
|
|
827
|
+
else
|
|
828
|
+
v1 = vars.shift
|
|
829
|
+
v10 = v1[0]
|
|
830
|
+
line[pos] = v10
|
|
831
|
+
line[(pos + 1)..] = (v1[1..] || [])
|
|
832
|
+
cont.variations = []
|
|
833
|
+
v10.variations = vars
|
|
834
|
+
end
|
|
835
|
+
else
|
|
836
|
+
vars.delete_at(i - 1)
|
|
837
|
+
end
|
|
838
|
+
@game.root
|
|
839
|
+
end
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
843
|
+
|
|
844
|
+
Run: `bundle _2.7.2_ exec rspec spec/node_spec.rb`
|
|
845
|
+
Expected: PASS.
|
|
846
|
+
|
|
847
|
+
- [ ] **Step 5: Run the full suite**
|
|
848
|
+
|
|
849
|
+
Run: `bundle _2.7.2_ exec rspec`
|
|
850
|
+
Expected: PASS — all examples, 0 failures.
|
|
851
|
+
|
|
852
|
+
- [ ] **Step 6: Commit**
|
|
853
|
+
|
|
854
|
+
```bash
|
|
855
|
+
git add lib/pgn/node.rb spec/node_spec.rb
|
|
856
|
+
git commit -m "feat(node): delete (promote next variation or truncate)"
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
---
|
|
860
|
+
|
|
861
|
+
### Task 6: Extended round-trip gate on fixtures
|
|
862
|
+
|
|
863
|
+
**Files:**
|
|
864
|
+
- Modify: `spec/game_spec.rb` (additive block)
|
|
865
|
+
|
|
866
|
+
- [ ] **Step 1: Write the failing test**
|
|
867
|
+
|
|
868
|
+
```ruby
|
|
869
|
+
# append to spec/game_spec.rb, inside the top-level `describe PGN::Game do`
|
|
870
|
+
describe 'node mutation round-trip' do
|
|
871
|
+
non_round_trip = %w[doublequotes.pgn specialcharacters.pgn].freeze
|
|
872
|
+
|
|
873
|
+
it 'every fixture survives promote/demote/add_variation and re-parses' do
|
|
874
|
+
fixtures = `git ls-files spec/pgn_files/`.split.map { |p| File.expand_path(p) }
|
|
875
|
+
fixtures.reject! { |p| non_round_trip.include?(File.basename(p)) }
|
|
876
|
+
fixtures.each do |path|
|
|
877
|
+
PGN.parse(File.read(path)).each do |game|
|
|
878
|
+
root = game.root
|
|
879
|
+
# find the first node that has variations
|
|
880
|
+
branch = enum_for_first_branch(root)
|
|
881
|
+
next unless branch
|
|
882
|
+
|
|
883
|
+
branch.variations.first&.promote
|
|
884
|
+
game.root # fresh tree
|
|
885
|
+
branch2 = enum_for_first_branch(game.root)
|
|
886
|
+
branch2&.demote if branch2
|
|
887
|
+
branch3 = enum_for_first_branch(game.root)
|
|
888
|
+
branch3&.add_variation('Nf3') if branch3
|
|
889
|
+
once = game.to_pgn
|
|
890
|
+
reparsed = PGN.parse(once).first
|
|
891
|
+
expect(PGN.parse(reparsed.to_pgn).first.to_pgn).to eq(once),
|
|
892
|
+
"#{path}: mutated game must round-trip"
|
|
893
|
+
end
|
|
894
|
+
end
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
# Walk to the first node (DFS over children) that has >= 1 variation.
|
|
898
|
+
def enum_for_first_branch(node)
|
|
899
|
+
stack = [node]
|
|
900
|
+
until stack.empty?
|
|
901
|
+
n = stack.shift
|
|
902
|
+
return n unless n.variations.empty?
|
|
903
|
+
stack.concat(n.children)
|
|
904
|
+
end
|
|
905
|
+
nil
|
|
906
|
+
end
|
|
907
|
+
end
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
911
|
+
|
|
912
|
+
Run: `bundle _2.7.2_ exec rspec spec/game_spec.rb`
|
|
913
|
+
Expected: FAIL (add_variation on a node whose branching point is terminal, or
|
|
914
|
+
a fixture with no variations) — adjust the helper to skip terminal/no-variation
|
|
915
|
+
cases (it already `next unless branch` / guards `&.`).
|
|
916
|
+
|
|
917
|
+
- [ ] **Step 3: No implementation needed** — this exercises Tasks 1–5.
|
|
918
|
+
|
|
919
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
920
|
+
|
|
921
|
+
Run: `bundle _2.7.2_ exec rspec spec/game_spec.rb`
|
|
922
|
+
Expected: PASS. (If a fixture triggers an ArgumentError from `add_variation`
|
|
923
|
+
on a terminal branch, the `enum_for_first_branch` helper must pick a branch
|
|
924
|
+
whose *next* move exists — `add_variation` operates at the branch node whose
|
|
925
|
+
continuation is non-nil; a node with variations always has a non-nil
|
|
926
|
+
continuation, so this is safe. Verify by running.)
|
|
927
|
+
|
|
928
|
+
- [ ] **Step 5: Commit**
|
|
929
|
+
|
|
930
|
+
```bash
|
|
931
|
+
git add spec/game_spec.rb
|
|
932
|
+
git commit -m "test(game): node-mutation round-trip gate across all fixtures"
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
---
|
|
936
|
+
|
|
937
|
+
### Task 7: Docs — README, CHANGELOG, TODO
|
|
938
|
+
|
|
939
|
+
**Files:**
|
|
940
|
+
- Modify: `README.md` (add a "Game-tree API" section)
|
|
941
|
+
- Modify: `CHANGELOG.md` (add entry)
|
|
942
|
+
- Modify: `TODO.md` (check off the item)
|
|
943
|
+
|
|
944
|
+
- [ ] **Step 1: Write the docs**
|
|
945
|
+
|
|
946
|
+
Add a README section after the "Generating SAN from coordinates" section:
|
|
947
|
+
|
|
948
|
+
```markdown
|
|
949
|
+
### Navigating and mutating the game tree
|
|
950
|
+
|
|
951
|
+
{PGN::Game#root} returns a navigable {PGN::Node} tree over the mainline and
|
|
952
|
+
its variations. A node knows its parent, its children (the mainline
|
|
953
|
+
continuation first, then the variations), and the {PGN::Position} it
|
|
954
|
+
represents. Mutations (`add_variation`, `promote`, `demote`,
|
|
955
|
+
`promote_to_main`, `delete`) edit the underlying structure in place; call
|
|
956
|
+
`#root` again for a fresh tree.
|
|
957
|
+
|
|
958
|
+
```ruby
|
|
959
|
+
game = PGN.parse(File.read("./examples/immortal_game.pgn")).first
|
|
960
|
+
root = game.root
|
|
961
|
+
root.main_line.map(&:notation) # => ["e4", "e5", ...]
|
|
962
|
+
root.next.next.children.map(&:notation) # alternatives at that position
|
|
963
|
+
root.next.next.position.to_fen.to_s # the FEN after 1.e4 e5
|
|
964
|
+
|
|
965
|
+
root.next.next.add_variation("Nc6") # add a variation
|
|
966
|
+
root = game.root # fresh tree after mutation
|
|
967
|
+
root.next.next.variations.first.promote_to_main
|
|
968
|
+
game.to_pgn # serialized with the new mainline
|
|
969
|
+
```
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
CHANGELOG entry under the unreleased heading:
|
|
973
|
+
|
|
974
|
+
```markdown
|
|
975
|
+
### Added
|
|
976
|
+
- `PGN::Node` — a navigable, mutable game-tree view (`PGN::Game#root`) with
|
|
977
|
+
per-node `Position` (pure-Ruby replay) and variation management
|
|
978
|
+
(`add_variation`, `add_main_variation`, `promote`, `demote`,
|
|
979
|
+
`promote_to_main`, `demote_to_last`, `delete`). Non-breaking; `Game#moves`
|
|
980
|
+
and `MoveText` keep their existing shapes and the PGN round-trip stays
|
|
981
|
+
byte-identical for un-mutated games.
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
TODO.md: change `- [ ] Richer game-tree API: ...` to `- [x] Richer game-tree
|
|
985
|
+
API: node-style mainline/variations, add/promote/demote variations.`
|
|
986
|
+
|
|
987
|
+
- [ ] **Step 2: Commit**
|
|
988
|
+
|
|
989
|
+
```bash
|
|
990
|
+
git add README.md CHANGELOG.md TODO.md
|
|
991
|
+
git commit -m "docs: document PGN::Node game-tree API"
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
---
|
|
995
|
+
|
|
996
|
+
## Self-Review
|
|
997
|
+
|
|
998
|
+
**Spec coverage:**
|
|
999
|
+
- Node abstraction + parent/child/variation links → Task 1. ✓
|
|
1000
|
+
- per-node Position (lazy, pure-Ruby) → Task 2. ✓
|
|
1001
|
+
- add_variation / add_main_variation → Task 3. ✓
|
|
1002
|
+
- promote / demote / promote_to_main / demote_to_last → Task 4. ✓
|
|
1003
|
+
- demote-at-index-0 inverse swap → Task 4 test. ✓
|
|
1004
|
+
- delete → Task 5. ✓
|
|
1005
|
+
- flatten-on-mutation for nested same-point variations → Task 4 test. ✓
|
|
1006
|
+
- backward compat (Game#moves, MoveText unchanged) → Tasks 1,6 keep suite green. ✓
|
|
1007
|
+
- round-trip gate extended → Task 6. ✓
|
|
1008
|
+
- docs → Task 7. ✓
|
|
1009
|
+
|
|
1010
|
+
**Placeholder scan:** none — all code blocks contain real code.
|
|
1011
|
+
|
|
1012
|
+
**Type consistency:** `Node.new(move:, parent:, line:, index:, starting_position:, game:)` keyword signature used consistently. `make_mainline(cont, vk, others, old_main_position:)` consistent across Tasks 3/4. `parent_continuation`, `sibling_index`, `normalize_branch_point`, `build_movetexts` defined once and reused. Mutation methods all return `@game.root`.
|
|
1013
|
+
|
|
1014
|
+
**One known approximation:** mutation methods return a fresh `game.root` (not the precise "affected node"). The spec said "freshly-affected Node(s)"; returning the root is the faithful, simple realization — callers re-navigate from the root. Documented in Global Constraints.
|