pgn2 0.4.0 → 1.1.0

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +50 -0
  3. data/.github/workflows/publish.yml +75 -0
  4. data/.github/workflows/release.yml +103 -0
  5. data/.gitignore +2 -1
  6. data/.rubocop.yml +38 -0
  7. data/CHANGELOG.md +84 -0
  8. data/README.md +122 -4
  9. data/Rakefile +20 -0
  10. data/TODO.md +9 -0
  11. data/bench/.keep +0 -0
  12. data/bench/IMPROVEMENTS.md +143 -0
  13. data/bench/baseline_moves.pre-optimization.txt +22 -0
  14. data/bench/baseline_moves.pre-quickwins.txt +23 -0
  15. data/bench/baseline_moves.txt +22 -0
  16. data/bench/baseline_parse.pre-optimization.txt +25 -0
  17. data/bench/baseline_parse.pre-quickwins.txt +26 -0
  18. data/bench/baseline_parse.racc.txt +25 -0
  19. data/bench/baseline_parse.txt +25 -0
  20. data/bench/profile_moves.rb +53 -0
  21. data/bench/profile_parse.rb +44 -0
  22. data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
  23. data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
  24. data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
  25. data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
  26. data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
  27. data/docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md +227 -0
  28. data/lib/pgn/board.rb +33 -15
  29. data/lib/pgn/fen.rb +16 -8
  30. data/lib/pgn/game.rb +23 -3
  31. data/lib/pgn/lexer.rb +223 -0
  32. data/lib/pgn/move.rb +12 -5
  33. data/lib/pgn/move_calculator.rb +27 -21
  34. data/lib/pgn/parser.rb +13 -203
  35. data/lib/pgn/pgn_parser.rb +393 -0
  36. data/lib/pgn/pgn_parser.y +140 -0
  37. data/lib/pgn/position.rb +3 -2
  38. data/lib/pgn/serializer.rb +141 -0
  39. data/lib/pgn/version.rb +1 -1
  40. data/lib/pgn.rb +3 -0
  41. data/pgn2.gemspec +12 -2
  42. data/spec/board_spec.rb +111 -0
  43. data/spec/fen_spec.rb +25 -0
  44. data/spec/game_spec.rb +74 -0
  45. data/spec/lexer_spec.rb +153 -0
  46. data/spec/move_calculator_spec.rb +226 -0
  47. data/spec/move_spec.rb +136 -0
  48. data/spec/parser_explicit_spec.rb +210 -0
  49. data/spec/parser_spec.rb +6 -23
  50. data/spec/pgn_files/doublequotes.pgn +21 -0
  51. data/spec/pgn_files/specialcharacters.pgn +79 -0
  52. data/spec/position_spec.rb +73 -0
  53. data/spec/serializer_spec.rb +89 -0
  54. data/spec/spec_helper.rb +0 -1
  55. metadata +103 -15
@@ -0,0 +1,162 @@
1
+ # `PGN::Game#to_pgn` serializer — 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:** Produce a canonical PGN string from a `PGN::Game` via `PGN::Game#to_pgn`, backed by a new `PGN::Serializer` class, so that parse → `to_pgn` → parse round-trips.
6
+
7
+ **Architecture:** A new `PGN::Serializer` class converts a `PGN::Game` into a String (tags section + blank line + movetext section, trailing newline). `PGN::Game#to_pgn` delegates to it. Serialization is purely structural (no board replay, no legality checks), seeding movetext numbering state from `game.starting_position.fullmove`/`player`.
8
+
9
+ **Tech Stack:** Ruby, RSpec, the existing `pgn2` gem (whittle parser).
10
+
11
+ ## Global Constraints
12
+
13
+ - No changes to the parser in this sub-project.
14
+ - No move legality validation; serialize moves as given.
15
+ - No line wrapping; v1 emits a single movetext line.
16
+ - Returns a String ending with a trailing newline.
17
+ - No app-specific behavior; nothing here knows about `chessellence`.
18
+ - `nil`/empty annotation, comment, or variations are omitted (no empty `{}`).
19
+ - Tag values escape `\` and `"`; comments escape `\`, `{`, `}`.
20
+
21
+ ---
22
+
23
+ ## File Structure
24
+
25
+ - Create: `lib/pgn/serializer.rb` — `PGN::Serializer`, all serialization logic.
26
+ - Modify: `lib/pgn.rb` — `require 'pgn/serializer'`.
27
+ - Modify: `lib/pgn/game.rb` — add `PGN::Game#to_pgn` delegating to `PGN::Serializer`.
28
+ - Create: `spec/serializer_spec.rb` — RSpec cases for the serializer.
29
+ - Modify: `spec/game_spec.rb` — add a few `#to_pgn` cases.
30
+
31
+ ---
32
+
33
+ ## Task 1: `PGN::Serializer` core — tags + simple movetext
34
+
35
+ **Files:**
36
+ - Create: `lib/pgn/serializer.rb`
37
+ - Modify: `lib/pgn.rb`
38
+ - Test: `spec/serializer_spec.rb`
39
+
40
+ **Interfaces:**
41
+ - Produces: `PGN::Serializer.new(game)` and `PGN::Serializer#to_s` returning the canonical PGN string (with trailing newline).
42
+
43
+ - [ ] **Step 1: Write failing tests** for the simple cases in `spec/serializer_spec.rb`:
44
+ - Tags + result: `PGN::Game.new(%w[e4 e5], { 'White' => 'A', 'Black' => 'B' }, '1-0')` → `[White "A"]\n[Black "B"]\n\n1. e4 e5 1-0\n`.
45
+ - No tags → `[Result "*"]\n\n1. e4 e5 *\n`.
46
+ - Empty game: `PGN::Game.new([], nil, '*')` → `[Result "*"]\n\n*\n`.
47
+ - No result → ends with `*\n`.
48
+
49
+ - [ ] **Step 2: Run tests, verify they fail** with `NameError` (no `PGN::Serializer`).
50
+
51
+ Run: `bundle exec rspec spec/serializer_spec.rb`
52
+ Expected: FAIL, uninitialized constant `PGN::Serializer`.
53
+
54
+ - [ ] **Step 3: Implement `PGN::Serializer`** (`lib/pgn/serializer.rb`) with tag escaping, the synthesized `Result` tag, movetext numbering (white `n.`, black `n...` only when needed), and result. Add `require 'pgn/serializer'` to `lib/pgn.rb`.
55
+
56
+ - [ ] **Step 4: Run tests, verify pass.**
57
+
58
+ Run: `bundle exec rspec spec/serializer_spec.rb`
59
+ Expected: PASS.
60
+
61
+ - [ ] **Step 5: Commit.**
62
+ `git add lib/pgn/serializer.rb lib/pgn.rb spec/serializer_spec.rb && git commit -m "feat: add PGN::Serializer with tags and basic movetext"`
63
+
64
+ ---
65
+
66
+ ## Task 2: Annotations, comments, and variations
67
+
68
+ **Files:**
69
+ - Modify: `lib/pgn/serializer.rb`
70
+ - Test: `spec/serializer_spec.rb`
71
+
72
+ **Interfaces:**
73
+ - Consumes: `MoveText#notation`, `#annotation` (Array), `#comment` (String), `#variations` (Array of Arrays of `MoveText`).
74
+ - Produces: move tokens `notation + annotation + {comment} + (variation)`; recursive variation serialization starting from the position before the move.
75
+
76
+ - [ ] **Step 1: Write failing tests:**
77
+ - Castling serializes as `O-O` / `O-O-O` (`PGN::Game.new(%w[O-O O-O-O])`).
78
+ - Annotations: a move with annotation `['$4']` → `e4 $4`; `['??']` → `e4 ??`.
79
+ - Two annotations: `['$2', '$11']` → `d5 $2 $11`.
80
+ - Comment: move with comment `c` → `e4 {c}`.
81
+ - Variations reproduces `spec/pgn_files/variations.pgn` movetext shape, including `2...` after variations: expected movetext `1. e4 e5 2. Nf3 {comment} (2. Nc3 {other} d5 (2... f5) 3. exd5) (2. f4 exf4 {final variation}) 2... Nf6 *` (plus tag section `[White "Somebody"]\n[Black "Petrov"]`).
82
+
83
+ - [ ] **Step 2: Run tests, verify they fail.**
84
+
85
+ Run: `bundle exec rspec spec/serializer_spec.rb -e "annotation\|comment\|variation\|Castling"`
86
+ Expected: FAIL.
87
+
88
+ - [ ] **Step 3: Implement** move-token assembly (notation + annotation + comment + recursive variations) and `prev_had_extras`/`prev_player` numbering rules from the spec. Variations start from the same `fullmove`/`player` as the move they attach to and do not affect enclosing state.
89
+
90
+ - [ ] **Step 4: Run tests, verify pass.**
91
+
92
+ Run: `bundle exec rspec spec/serializer_spec.rb`
93
+ Expected: PASS.
94
+
95
+ - [ ] **Step 5: Commit.**
96
+ `git add lib/pgn/serializer.rb spec/serializer_spec.rb && git commit -m "feat: serialize annotations, comments, and variations"`
97
+
98
+ ---
99
+
100
+ ## Task 3: Game comment, FEN start, and `--` moves
101
+
102
+ **Files:**
103
+ - Modify: `lib/pgn/serializer.rb`
104
+ - Test: `spec/serializer_spec.rb`
105
+
106
+ - [ ] **Step 1: Write failing tests:**
107
+ - Game comment only: `PGN::Game.new([], nil, '*', nil, 'game comment')` → `[Result "*"]\n\n{ game comment } *\n`.
108
+ - FEN start with black to move: a game built from `spec/pgn_files/fen.pgn`-style FEN where `active` is `b`, first move numbered `1...`. Construct via `PGN.parse` of a PGN with a `FEN` tag whose active color is `b`.
109
+ - `--` move serializes verbatim and alternates color/fullmove: `PGN::Game.new(%w[-- e4])` → `1. -- e4`.
110
+
111
+ - [ ] **Step 2: Run tests, verify they fail.**
112
+
113
+ Run: `bundle exec rspec spec/serializer_spec.rb -e "game comment\|FEN\|don"`
114
+ Expected: FAIL.
115
+
116
+ - [ ] **Step 3: Implement** game-comment emission (first movetext token, wrapped in `{ }`), and confirm FEN seeding already works via `starting_position.fullmove`/`player` (no board replay). Add comment escaping.
117
+
118
+ - [ ] **Step 4: Run tests, verify pass.**
119
+
120
+ Run: `bundle exec rspec spec/serializer_spec.rb`
121
+ Expected: PASS.
122
+
123
+ - [ ] **Step 5: Commit.**
124
+ `git add lib/pgn/serializer.rb spec/serializer_spec.rb && git commit -m "feat: serialize game comments, FEN-start numbering, and -- moves"`
125
+
126
+ ---
127
+
128
+ ## Task 4: `PGN::Game#to_pgn` + round-trip fixture tests
129
+
130
+ **Files:**
131
+ - Modify: `lib/pgn/game.rb`
132
+ - Modify: `spec/game_spec.rb`
133
+
134
+ **Interfaces:**
135
+ - Produces: `PGN::Game#to_pgn` returning `PGN::Serializer.new(self).to_s`.
136
+
137
+ - [ ] **Step 1: Write failing tests** in `spec/game_spec.rb`:
138
+ - `#to_pgn` returns a string ending in newline for `PGN::Game.new(%w[e4 e5], { 'White' => 'A' }, '1-0')`.
139
+ - Round-trip: for each fixture in `spec/pgn_files`, parse → `to_pgn` → parse, compare `result`, `moves` (notation), per-move `annotation`/`comment`/`variations`, and that reparsed `tags` are a superset of original (a no-tag game gains a `Result` tag).
140
+
141
+ - [ ] **Step 2: Run tests, verify they fail** (no `#to_pgn`).
142
+
143
+ Run: `bundle exec rspec spec/game_spec.rb`
144
+ Expected: FAIL, undefined method `to_pgn`.
145
+
146
+ - [ ] **Step 3: Implement `PGN::Game#to_pgn`** delegating to `PGN::Serializer`.
147
+
148
+ - [ ] **Step 4: Run full suite, verify pass.**
149
+
150
+ Run: `bundle exec rspec`
151
+ Expected: PASS (all examples).
152
+
153
+ - [ ] **Step 5: Commit.**
154
+ `git add lib/pgn/game.rb spec/game_spec.rb && git commit -m "feat: add PGN::Game#to_pgn with round-trip fixture tests"`
155
+
156
+ ---
157
+
158
+ ## Self-Review
159
+
160
+ - **Spec coverage:** tag section + synthesized Result tag (T1), movetext numbering incl. `n...` rules (T1/T2), move token with annotation/comment/variations (T2), recursive variations from prior position (T2), game comment (T3), FEN/black-to-move seeding (T3), `--` (T3), `to_pgn` API + trailing newline (T1/T4), escaping (T1/T3), edge cases (T1/T3), round-trip fixture tests (T4). ✓
161
+ - **Placeholder scan:** no TBD/TODO. ✓
162
+ - **Type consistency:** `PGN::Serializer.new(game)`, `#to_s`, `PGN::Game#to_pgn` consistent across tasks. `MoveText` accessors match existing `game.rb` (`notation`, `annotation`, `comment`, `variations`). ✓
@@ -0,0 +1,130 @@
1
+ # Plan: Migrate PGN parser from whittle to Racc + StringScanner
2
+
3
+ Date: 2026-08-13
4
+ Status: **COMPLETE** (all 8 tasks done; 187 examples green; whittle removed).
5
+ Branch: `racc-migration` (branched from `faster` after baseline commit)
6
+ Fallback: tag `pre-racc` on the pre-migration commit.
7
+
8
+ Result: parse-only allocations -55% objects / -70% bytes, parse-only
9
+ throughput ~3.5x faster (741 -> 212 ms/i). See `bench/IMPROVEMENTS.md`.
10
+
11
+ ## Goal
12
+
13
+ Replace the abandoned `whittle` (0.0.8, 2011) parser with a maintained,
14
+ stdlib-only `Racc` + `StringScanner` parser that produces **byte-compatible**
15
+ `PGN::Game` objects: identical `tags`, `result`, `moves` (as `MoveText` with
16
+ `annotation`/`comment`/`variations`), `pgn` (verbatim raw text), and `comment`.
17
+ Keep every existing spec green and add many new parser tests.
18
+
19
+ ## Why
20
+
21
+ - whittle is unmaintained (14 years old) and accounts for ~80% of parse
22
+ allocations (`terminal.rb` 77k + `parser.rb` 22k of 125k per 50 games).
23
+ - A `StringScanner` (C ext) lexer replaces whittle's pure-Ruby per-token regex
24
+ churn — the dominant cost.
25
+ - Racc is stdlib runtime (`racc/parser`), maintained; drops whittle, adds no
26
+ runtime gem.
27
+ - Also fixes the `@@pgn`/`@@game_comment` class-variable reentrancy bug
28
+ (state moves to parser instances) and the O(n²) `@@pgn` string building.
29
+
30
+ ## Safety strategy (repo stays green at every checkpoint)
31
+
32
+ 1. Keep `whittle` fully working as `PGN::WhittleParser`; `PGN.parse` uses it
33
+ until the new parser is proven.
34
+ 2. Build `PGN::RaccParser` in parallel.
35
+ 3. **Golden-equivalence spec**: parse all 14 fixtures + every `parser_spec`
36
+ example string with BOTH parsers; assert the resulting `PGN::Game` objects
37
+ are equal (tags/result/moves/pgn/comment). Iterate the new parser until
38
+ green. This makes the fiddly `game.pgn` verbatim-text and `comment`
39
+ semantics **convergent, not hand-modelled**.
40
+ 4. Cut over `PGN::Parser` → `PGN::RaccParser` only when golden + full suite
41
+ pass.
42
+ 5. Remove `whittle` only after the full suite is green on Racc alone.
43
+
44
+ If any task cannot be made green, revert to whittle (repo stays on
45
+ `pre-racc`-equivalent state) and report partial progress.
46
+
47
+ ## Tasks
48
+
49
+ ### Task 0 — Stabilize baseline
50
+
51
+ - Commit the in-tree WIP (double-quotes regex + `Encoding` arg +
52
+ `spec_helper` cleanup + `board.rb` `@owned` COW refinement + `game.rb`
53
+ gsub-skip + new fixtures + CHANGELOG + this plan + the existing
54
+ efficiency plans) so the migration starts from a known, committed state.
55
+ - Tag `pre-racc`. Branch `racc-migration`. Verify `bundle exec rspec` green.
56
+
57
+ ### Task 1 — Preserve whittle as `PGN::WhittleParser`
58
+
59
+ - Rename `class Parser < Whittle::Parser` → `class WhittleParser` in
60
+ `lib/pgn/parser.rb`; keep `PGN::Parser` as a thin module/facade that
61
+ currently delegates to `WhittleParser` (`PGN.parse` unchanged behavior).
62
+ - Add `spec/golden_equivalence_spec.rb` scaffold that iterates all fixtures
63
+ and asserts `WhittleParser` vs `RaccParser` (RaccParser stubbed to skip
64
+ until Task 4). For now it just documents WhittleParser output is stable.
65
+
66
+ ### Task 2 — StringScanner lexer
67
+
68
+ - New `lib/pgn/lexer.rb`: `PGN::Lexer` yielding tokens `[:type, value, offset,
69
+ line]`. Reuses the **exact** whittle regexes (string, comment with `\g<1>`
70
+ recursion, game_termination, san_move, move_number_indication, tag_name,
71
+ numeric_annotation_glyph) so tokenization matches; skips `wsp` and
72
+ `%`-comments. Token precedence tuned for PGN (terminations and san_move
73
+ before move_number so `1-0`/`0-0` tokenize correctly).
74
+ - New `spec/lexer_spec.rb`: token-level tests for every token type on
75
+ representative inputs (including UTF-8 en-dash, inner quotes, nested
76
+ comments, NAGs, `--`, castling `0-0`/`O-O`, promotion, check/mate).
77
+
78
+ ### Task 3 — Racc grammar + generated parser
79
+
80
+ - New `lib/pgn/pgn_parser.y`: Racc grammar mirroring the whittle rules.
81
+ `variation_list` left-recursive (preserves variation order — fixes the
82
+ current "reverses on parse" quirk; `game_spec` compares order-independently
83
+ so this is safe). Recursive comments handled in the lexer.
84
+ - `Rakefile`: `rake generate` runs `racc lib/pgn/pgn_parser.y -o
85
+ lib/pgn/pgn_parser.rb`; commit the generated file (no runtime racc dep for
86
+ users). Add `racc` as a dev dependency in the gemspec.
87
+ - New `PGN::RaccParser` (`lib/pgn/racc_parser.rb`): `parse(input)` → array of
88
+ game hashes `{tags:, result:, moves:, pgn:, comment:}` matching the
89
+ whittle shape. Lexer/parse state held on the instance (no class vars).
90
+ `pgn` computed from per-game token offsets (start of first token → end of
91
+ game_termination); converged via the golden test.
92
+
93
+ ### Task 4 — Golden equivalence
94
+
95
+ - Complete `spec/golden_equivalence_spec.rb`: for each fixture and each
96
+ parser_spec example string, assert
97
+ `whittle_games == racc_games` where equality covers tags, result, each
98
+ move's notation/annotation/comment/variations (recursively), pgn, comment.
99
+ - Iterate lexer token order + grammar + `pgn` slicing until 100% green.
100
+
101
+ ### Task 5 — Cut over
102
+
103
+ - `PGN::Parser` → delegate to `PGN::RaccParser`. Run the **full** suite
104
+ (parser_spec, game_spec round-trip-all-fixtures, serializer_spec,
105
+ board/move/move_calculator/position/fen specs). All must pass.
106
+
107
+ ### Task 6 — Many new explicit parser tests
108
+
109
+ - Expand `spec/parser_spec.rb` with hardcoded-expected cases (no whittle
110
+ dependency): empty game, no-moves game, single move, comments, nested
111
+ comments, multiline comments, variations (incl. nested), annotations,
112
+ NAGs (`$1`, `?!`, `!?`), FEN tag, `--` move, castling `0-0`/`O-O-O`,
113
+ promotion `=Q`, check `+`, mate `#`, double-quotes-in-tag-value, UTF-8
114
+ special chars + `Encoding` arg, multiple games, game comment before moves,
115
+ `empty_variation_move`, `1/2-1/2` and `*` terminations.
116
+
117
+ ### Task 7 — Remove whittle
118
+
119
+ - Delete `PGN::WhittleParser` and `require 'whittle'`.
120
+ - Drop `whittle` from `pgn2.gemspec` runtime deps.
121
+ - Remove `spec/golden_equivalence_spec.rb` (its job is done) OR convert it to
122
+ committed-snapshot expectations (keep coverage but no whittle).
123
+ - `git grep -i whittle` must be clean.
124
+
125
+ ### Task 8 — Bench + verify
126
+
127
+ - Re-run `rake bench`; record parse-only and parse+replay before/after in
128
+ `bench/IMPROVEMENTS.md` (add a "parser migration" section).
129
+ - Final: `bundle exec rspec` all green; `git grep -i whittle` clean;
130
+ summarize deltas.
@@ -0,0 +1,217 @@
1
+ # `PGN::Game#to_pgn` serialization — design
2
+
3
+ - Date: 2026-08-12
4
+ - Status: Draft, awaiting review
5
+ - Repo: `git@github.com:muriloime/pgn.git` (gem `pgn2`)
6
+ - Sub-project: 1 of 4 (“add generic, reusable features to pgn2”)
7
+
8
+ ## Context
9
+
10
+ `chessellence` uses `pgn2` only to parse/validate lesson PGNs at seed time. We
11
+ want to enhance pgn2 with generic, reusable features. This is the first of four
12
+ independent sub-projects, each with its own design → plan → implementation cycle:
13
+
14
+ 1. **`PGN::Game#to_pgn` serializer** ← this document
15
+ 2. FEN helpers / position utilities (parse, validate, compare, normalize)
16
+ 3. Parser improvements (NAGs, recursive variations)
17
+ 4. Server-side move/FEN validation API (legal moves, check/checkmate/draw)
18
+
19
+ `to_pgn` is first because it is self-contained, closes a long-standing TODO in
20
+ the gem, is immediately useful for exporting/sharing games from `chessellence`,
21
+ and does not depend on a rules engine.
22
+
23
+ ## Goal
24
+
25
+ Produce a canonical PGN string from a `PGN::Game`’s structured data (tags,
26
+ moves, comments, annotations, variations, result), so that:
27
+
28
+ - `PGN.parse(game.to_pgn)` is equivalent to `game` (round-trippable).
29
+ - The output is valid PGN per the PGN spec for the features the parser already
30
+ supports (movetext, comments, NAGs/symbolic annotations, variations, FEN
31
+ start tag, game result).
32
+ - The gem remains free of app-specific behavior; nothing here knows about
33
+ `chessellence`.
34
+
35
+ ## Non-goals
36
+
37
+ - No move legality validation, no check/checkmate detection. A `PGN::Game`
38
+ built from arbitrary SAN serializes as given. (Validation is sub-project 4.)
39
+ - No line wrapping / 80-column formatting in v1. Output is a single movetext
40
+ line. Wrapping can be added later as an option without changing the core.
41
+ - No preservation of the original raw `pgn` formatting. `to_pgn` serializes
42
+ from structured data, not from the `Game#pgn` attribute.
43
+ - No changes to the parser in this sub-project.
44
+
45
+ ## API
46
+
47
+ ```ruby
48
+ class PGN::Game
49
+ # @return [String] a canonical PGN string for this game
50
+ def to_pgn
51
+ PGN::Serializer.new(self).to_s
52
+ end
53
+ end
54
+ ```
55
+
56
+ - Returns a String ending with a trailing newline.
57
+ - No options in v1 (YAGNI). A future `width:` option for line wrapping can be
58
+ added without breaking callers.
59
+
60
+ Implementation lives in a new `PGN::Serializer` class so `PGN::Game` stays thin
61
+ and the serialization logic is independently testable.
62
+
63
+ ## Output structure
64
+
65
+ A game serializes as up to two sections separated by a blank line:
66
+
67
+ 1. **Tag section** — one tag pair per line, in the order of `game.tags`:
68
+ `[Key "Value"]`. If `tags` is `nil` or empty, emit a synthesized
69
+ `[Result "<result or *>"]` tag instead. The current parser grammar
70
+ requires at least one tag pair, so this keeps no-tag games parseable; it
71
+ means a no-tag game round-trips with a single `Result` tag added.
72
+ 2. **Movetext section** — optional game comment, then moves, then result.
73
+
74
+ ```
75
+ [Event "Zurich Chess Challenge"]
76
+ [White "Carlsen, Magnus"]
77
+
78
+ 1. c4 g6 2. d4 Nf6 ... 1-0
79
+ ```
80
+
81
+ If there are no moves, the movetext section is just the game comment (if any)
82
+ followed by the result. (The tag section is never entirely omitted — see
83
+ above.)
84
+
85
+ ## Movetext rules
86
+
87
+ State tracked while emitting a line (mainline or variation):
88
+
89
+ - `fullmove` — starts from `game.starting_position.fullmove` (1 by default).
90
+ - `player` — starts from `game.starting_position.player` (`:white` by default).
91
+ - `prev_player` — the color of the previously emitted move in the current
92
+ line (`nil` at the start of a line/variation).
93
+ - `prev_had_extras` — whether the previous move emitted any annotation,
94
+ comment, or variation.
95
+
96
+ For each `MoveText` in the line:
97
+
98
+ - If `player == :white`:
99
+ - Emit `"<fullmove>."` then the move token.
100
+ - If `player == :black`:
101
+ - Emit `"<fullmove>..."` before the move token when a number is needed:
102
+ - at the start of a line/variation (`prev_player.nil?`),
103
+ - after a comment/annotation/variation on the previous move
104
+ (`prev_had_extras`),
105
+ - when the previous move was not white (`prev_player != :white`).
106
+ - Otherwise emit the move token with no number (conventional
107
+ `1. e4 e5 2. Nf3 ...` style).
108
+ - After emitting, update state:
109
+ - `prev_player = player`
110
+ - `prev_had_extras = move had annotation, comment, or variation`
111
+ - If `player == :black`, `fullmove += 1`.
112
+ - `player = opposite`.
113
+
114
+ This reproduces the conventional style and keeps black move numbers
115
+ unambiguous after comments/variations, e.g. the `variations.pgn` fixture:
116
+
117
+ ```
118
+ 1. e4 e5 2. Nf3 {comment} (2. Nc3 {other} d5 (2... f5) 3. exd5) (2. f4 exf4 {final variation}) 2... Nf6 *
119
+ ```
120
+
121
+ ### Move token
122
+
123
+ A move token is the notation plus trailing extras, joined by spaces:
124
+
125
+ 1. `move.notation` (e.g. `e4`, `O-O`, `Nef6+`, `--`).
126
+ 2. Each element of `move.annotation` (e.g. `$2`, `??`), in order.
127
+ 3. `move.comment` as `{ ... }`, if present.
128
+ 4. Each variation as `( ... )`, if present.
129
+
130
+ Variations are serialized recursively with the same movetext rules, starting
131
+ from the position **before** the move they are attached to (same `fullmove`
132
+ and `player` as that move). Variations do not affect the enclosing line’s
133
+ `fullmove`/`player` state.
134
+
135
+ ### Game comment
136
+
137
+ If `game.comment` is present, it is emitted as the first token of the movetext
138
+ section, wrapped as `{ ... }`, before any move numbers.
139
+
140
+ ### Result
141
+
142
+ The result token is appended last:
143
+
144
+ - `game.result` if present and non-empty.
145
+ - `"*"` otherwise.
146
+
147
+ ### Castling and `--`
148
+
149
+ - The parser already normalizes `0` to `O` in `Game#moves=`, so castling
150
+ serializes as `O-O` / `O-O-O`.
151
+ - `--` (“don’t care”) moves serialize verbatim and are treated as a normal ply
152
+ for color/fullmove alternation, matching `Position#move`’s behavior.
153
+
154
+ ## Starting position
155
+
156
+ `PGN::Game#starting_position` already returns a `PGN::Position` from the `FEN`
157
+ tag (or the standard start). The serializer uses only its `fullmove` and
158
+ `player` to seed movetext state. It does **not** replay moves on a board, so
159
+ invalid games still serialize. This means a game starting with black to move
160
+ from a FEN tag emits its first move as `1... ...`.
161
+
162
+ ## Escaping
163
+
164
+ - **Tag values:** escape `\` and `"`:
165
+ `value.to_s.gsub('\\', '\\\\\\\\').gsub('"', '\\"')`.
166
+ - **Comments:** wrap in `{ ... }` and escape `\`, `{`, and `}` for correctness.
167
+ Note: the current parser’s `MoveText#clean_text` does not unescape inner
168
+ braces, so a comment containing escaped braces will not round-trip
169
+ byte-for-byte until parser improvements (sub-project 3) add unescaping. For
170
+ v1, tests avoid literal braces inside comment text; the serializer is still
171
+ correct for the spec.
172
+ - **Annotations:** emitted verbatim (`$n` or symbolic `?!` forms).
173
+ - **Notation:** emitted verbatim.
174
+
175
+ ## Edge cases
176
+
177
+ - No tags → emit a synthesized `[Result "<result or *>"]` tag (keeps output
178
+ parseable by the current grammar; a no-tag game round-trips with that tag
179
+ added).
180
+ - No moves → emit game comment (if any) and result only.
181
+ - No result → emit `*`.
182
+ - `nil`/empty annotation or comment → omit (do not emit empty `{}`).
183
+ - `nil`/empty variations → omit.
184
+ - Game starting with black to move (FEN tag) → first move numbered `1...`.
185
+ - Manually constructed game (`PGN::Game.new(%w[e4 e5])`) → tag section
186
+ `[Result "*"]` then `1. e4 e5 *`.
187
+
188
+ ## Testing
189
+
190
+ New `spec/serializer_spec.rb` (RSpec, matching existing style) plus a few
191
+ `#to_pgn` cases in `spec/game_spec.rb`. Cases:
192
+
193
+ 1. Simple game with tags and result:
194
+ `PGN::Game.new(%w[e4 e5], { 'White' => 'A', 'Black' => 'B' }, '1-0')`
195
+ → `[White "A"]\n[Black "B"]\n\n1. e4 e5 1-0`.
196
+ 2. Manually constructed game with no tags → `[Result "*"]\n\n1. e4 e5 *`.
197
+ 3. Castling serializes as `O-O` / `O-O-O`.
198
+ 4. Annotations: `$4`, `??` emitted after notation.
199
+ 5. Comments and variations: reproduces `variations.pgn` movetext shape,
200
+ including `2...` after variations.
201
+ 6. FEN start with black to move → first move `1...`.
202
+ 7. Empty game: `PGN::Game.new([], nil, '*')` → `*`.
203
+ 8. Game comment only: `PGN::Game.new([], nil, '*', nil, 'game comment')`
204
+ → `{ game comment } *`.
205
+ 9. **Round-trip:** for each fixture in `spec/pgn_files`, parse → `to_pgn` →
206
+ parse again, then compare `result`, `moves` (notation), per-move
207
+ `annotation`, `comment`, and `variations`, and that the reparsed `tags` are a
208
+ superset of the original (a no-tag game gains a `Result` tag). This does not
209
+ require byte-for-byte equality with the original file.
210
+
211
+ ## Future considerations (not in this sub-project)
212
+
213
+ - Optional line wrapping (`width:` argument) to respect the PGN 80-column
214
+ recommendation.
215
+ - Canonical Seven-Tag-Roster ordering/normalization option.
216
+ - Exact raw-PGN preservation is out of scope; callers that want the original
217
+ source should use `Game#pgn`.