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.
@@ -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.