pgn2 0.4.0 → 1.0.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 (48) 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/.rubocop.yml +38 -0
  5. data/CHANGELOG.md +52 -0
  6. data/README.md +104 -4
  7. data/Rakefile +20 -0
  8. data/bench/.keep +0 -0
  9. data/bench/IMPROVEMENTS.md +75 -0
  10. data/bench/baseline_moves.pre-optimization.txt +22 -0
  11. data/bench/baseline_moves.txt +22 -0
  12. data/bench/baseline_parse.pre-optimization.txt +25 -0
  13. data/bench/baseline_parse.racc.txt +25 -0
  14. data/bench/baseline_parse.txt +25 -0
  15. data/bench/profile_moves.rb +53 -0
  16. data/bench/profile_parse.rb +44 -0
  17. data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
  18. data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
  19. data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
  20. data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
  21. data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
  22. data/lib/pgn/board.rb +33 -15
  23. data/lib/pgn/fen.rb +16 -8
  24. data/lib/pgn/game.rb +11 -2
  25. data/lib/pgn/lexer.rb +201 -0
  26. data/lib/pgn/move.rb +7 -3
  27. data/lib/pgn/move_calculator.rb +18 -17
  28. data/lib/pgn/parser.rb +19 -199
  29. data/lib/pgn/pgn_parser.rb +392 -0
  30. data/lib/pgn/pgn_parser.y +142 -0
  31. data/lib/pgn/serializer.rb +141 -0
  32. data/lib/pgn/version.rb +1 -1
  33. data/lib/pgn.rb +3 -0
  34. data/pgn2.gemspec +12 -2
  35. data/spec/board_spec.rb +111 -0
  36. data/spec/fen_spec.rb +25 -0
  37. data/spec/game_spec.rb +74 -0
  38. data/spec/lexer_spec.rb +153 -0
  39. data/spec/move_calculator_spec.rb +226 -0
  40. data/spec/move_spec.rb +136 -0
  41. data/spec/parser_explicit_spec.rb +210 -0
  42. data/spec/parser_spec.rb +15 -0
  43. data/spec/pgn_files/doublequotes.pgn +21 -0
  44. data/spec/pgn_files/specialcharacters.pgn +79 -0
  45. data/spec/position_spec.rb +73 -0
  46. data/spec/serializer_spec.rb +89 -0
  47. data/spec/spec_helper.rb +0 -1
  48. metadata +99 -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`.
data/lib/pgn/board.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module PGN
2
4
  # {PGN::Board} represents the squares of a chess board and the pieces on
3
5
  # each square. It is responsible for translating between a human readable
@@ -73,6 +75,7 @@ module PGN
73
75
  #
74
76
  def initialize(squares)
75
77
  self.squares = squares
78
+ @owned = Array.new(8, false)
76
79
  end
77
80
 
78
81
  # @overload at(str)
@@ -88,13 +91,12 @@ module PGN
88
91
  # board.at(4,3) #=> "P"
89
92
  # board.at("e4") #=> "P"
90
93
  #
91
- def at(*args)
92
- case args.length
93
- when 1
94
- at(*coordinates_for(args.first))
95
- when 2
96
- squares[args[0]][args[1]]
97
- end
94
+ # String squares are parsed with getbyte arithmetic (a=0x61, '1'=0x31)
95
+ # so the common string lookup allocates nothing.
96
+ def at(arg0, arg1 = nil)
97
+ return squares[arg0][arg1] unless arg1.nil?
98
+
99
+ squares[file_of(arg0)][rank_of(arg0)]
98
100
  end
99
101
 
100
102
  # @param changes [Hash<String, <String, nil>>] changes to make to the board
@@ -115,9 +117,16 @@ module PGN
115
117
  # @example
116
118
  # board.update("e4", "P")
117
119
  #
120
+ # Copy-on-write: clone only the column being mutated, and only once per
121
+ # instance, so unchanged columns stay shared with any board this one was
122
+ # duped from.
118
123
  def update(square, piece)
119
- coords = coordinates_for(square)
120
- squares[coords[0]][coords[1]] = piece
124
+ file = file_of(square)
125
+ unless @owned[file]
126
+ squares[file] = squares[file].dup
127
+ @owned[file] = true
128
+ end
129
+ squares[file][rank_of(square)] = piece
121
130
  self
122
131
  end
123
132
 
@@ -127,10 +136,7 @@ module PGN
127
136
  # board.coordinates_for("e4") #=> [4, 3]
128
137
  #
129
138
  def coordinates_for(position)
130
- file_chr, rank_chr = position.chars.to_a
131
- file = FILE_TO_INDEX[file_chr]
132
- rank = RANK_TO_INDEX[rank_chr]
133
- [file, rank]
139
+ [file_of(position), rank_of(position)]
134
140
  end
135
141
 
136
142
  # @param coordinates [Array<Integer>] the coordinates of the square
@@ -154,10 +160,22 @@ module PGN
154
160
  end.join("\n")
155
161
  end
156
162
 
157
- # @return [PGN::Board] a copy of self with duplicated squares
163
+ # @return [PGN::Board] a copy of self. The outer array is copied; the
164
+ # 8 column arrays are shared and cloned lazily by #update on first
165
+ # mutation (copy-on-write).
158
166
  #
159
167
  def dup
160
- PGN::Board.new(squares.map(&:dup))
168
+ PGN::Board.new(squares.dup)
169
+ end
170
+
171
+ private
172
+
173
+ def file_of(square)
174
+ square.getbyte(0) - 97
175
+ end
176
+
177
+ def rank_of(square)
178
+ square.getbyte(1) - 49
161
179
  end
162
180
  end
163
181
  end
data/lib/pgn/fen.rb CHANGED
@@ -98,14 +98,22 @@ module PGN
98
98
  # PGN::FEN.start.board_string #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
99
99
  #
100
100
  def board_string
101
- self.board
102
- .squares
103
- .transpose
104
- .reverse
105
- .map {|row| row.map {|e| e.nil? ? "_" : e } }
106
- .map {|row| row.join }
107
- .join("/")
108
- .gsub(/_+/) {|match| match.length }
101
+ rows = self.board.squares.transpose.reverse
102
+ rows.map do |row|
103
+ s = +""
104
+ run = 0
105
+ row.each do |e|
106
+ if e.nil?
107
+ run += 1
108
+ else
109
+ s << run.to_s if run > 0
110
+ run = 0
111
+ s << e
112
+ end
113
+ end
114
+ s << run.to_s if run > 0
115
+ s
116
+ end.join("/")
109
117
  end
110
118
 
111
119
  # @return [PGN::Position] a {PGN::Position} representing the current
data/lib/pgn/game.rb CHANGED
@@ -81,13 +81,22 @@ module PGN
81
81
  @moves =
82
82
  moves.map do |m|
83
83
  if m.is_a? String
84
- MoveText.new(m.gsub('0', 'O'))
84
+ MoveText.new(m.include?('0') ? m.gsub('0', 'O') : m)
85
85
  else
86
- MoveText.new(m.notation.gsub('0', 'O'), m.annotation, m.comment, m.variations)
86
+ notation = m.notation
87
+ notation = notation.gsub('0', 'O') if notation.include?('0')
88
+ MoveText.new(notation, m.annotation, m.comment, m.variations)
87
89
  end
88
90
  end
89
91
  end
90
92
 
93
+ # @return [String] a canonical PGN string for this game, ending with a
94
+ # trailing newline.
95
+ #
96
+ def to_pgn
97
+ PGN::Serializer.new(self).to_s
98
+ end
99
+
91
100
  def initial_fen
92
101
  tags && tags['FEN']
93
102
  end