pgn2 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/release-gems.yml +5 -2
- data/CHANGELOG.md +69 -2
- data/README.md +27 -0
- data/Rakefile +18 -0
- data/TODO.md +25 -107
- data/docs/superpowers/plans/2026-08-15-game-tree-api-plan.md +1014 -0
- data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -0
- data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
- data/lib/pgn/attack.rb +97 -0
- data/lib/pgn/epd.rb +81 -0
- data/lib/pgn/fen.rb +8 -1
- data/lib/pgn/game.rb +84 -5
- data/lib/pgn/move.rb +4 -4
- data/lib/pgn/node.rb +372 -0
- data/lib/pgn/notation.rb +8 -66
- data/lib/pgn/pgn_parser.rb +30 -31
- data/lib/pgn/pgn_parser.y +14 -15
- data/lib/pgn/position.rb +191 -0
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +3 -0
- data/spec/castling_normalization_spec.rb +39 -0
- data/spec/comment_round_trip_spec.rb +35 -0
- data/spec/epd_spec.rb +44 -0
- data/spec/fen_spec.rb +6 -0
- data/spec/game_history_spec.rb +46 -0
- data/spec/game_spec.rb +60 -0
- data/spec/movetext_clean_spec.rb +44 -0
- data/spec/node_spec.rb +240 -0
- data/spec/outcome_spec.rb +93 -0
- data/spec/parser_left_recursion_spec.rb +37 -0
- data/spec/position_attack_spec.rb +56 -0
- data/spec/position_legal_spec.rb +123 -0
- metadata +18 -2
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# Richer game-tree API — node abstraction, variations management, promote/demote
|
|
2
|
+
|
|
3
|
+
**Status:** design (awaiting plan)
|
|
4
|
+
**Date:** 2026-08-15
|
|
5
|
+
**Sub-project:** 1 of 3 (game-tree API → engine wrapper → Polyglot/Syzygy)
|
|
6
|
+
**Branch:** `feature/game-tree-engine-book` (worktree `.worktrees/game-tree-engine-book`)
|
|
7
|
+
**TODO ref:** `TODO.md` Group 1 — "Richer game-tree API: node-style mainline/variations, add/promote/demote variations."
|
|
8
|
+
|
|
9
|
+
## Goal
|
|
10
|
+
|
|
11
|
+
Give `pgn2` a first-class, navigable, mutable game tree: a `PGN::Node`
|
|
12
|
+
abstraction over the existing `MoveText` structure with parent/child/variation
|
|
13
|
+
links, per-node `Position` (lazy replay), and variation-management operations
|
|
14
|
+
(`add_variation`, `promote`, `demote`, `promote_to_main`, `demote_to_last`,
|
|
15
|
+
`delete`). Non-breaking: the existing `Game#moves` / `MoveText` /
|
|
16
|
+
`Serializer` surface stays intact and byte-identical.
|
|
17
|
+
|
|
18
|
+
## Non-goals
|
|
19
|
+
|
|
20
|
+
- Legality validation of SAN on `add_variation` / `Node#position`. The gem is
|
|
21
|
+
structural throughout (parser and `Game.new` don't validate); `Node#position`
|
|
22
|
+
replays via pure-Ruby `Position#move` and raises on an illegal SAN exactly as
|
|
23
|
+
`Game#positions` does today. No new native-engine dependency.
|
|
24
|
+
- Nested-comment / brace-escaping round-trip improvements (separate TODO item).
|
|
25
|
+
- The UCI/XBoard engine wrapper and Polyglot/Syzygy parsers — separate
|
|
26
|
+
sub-projects on this same branch, designed and planned independently after
|
|
27
|
+
this one lands.
|
|
28
|
+
- Streaming/lazy PGN reader, EPD, tolerant parse mode — other TODO items.
|
|
29
|
+
|
|
30
|
+
## Source of truth
|
|
31
|
+
|
|
32
|
+
`MoveText` remains the source of truth.
|
|
33
|
+
|
|
34
|
+
- The parser builds `MoveText` trees as today; the serializer reads them. This
|
|
35
|
+
is what makes PGN round-trip byte-identical, and it stays untouched on the
|
|
36
|
+
read side.
|
|
37
|
+
- `PGN::Node` is a **live, lazily-built view** over the `MoveText` tree.
|
|
38
|
+
`Game#root` builds a fresh wrapper tree on each call: it wraps the existing
|
|
39
|
+
`MoveText` objects (no copy), recording each node's `MoveText`, its parent
|
|
40
|
+
`Node`, and the line + index it lives in (so it can locate its continuation).
|
|
41
|
+
- **All mutations go through `Node` methods that edit the underlying `MoveText`
|
|
42
|
+
arrays in place.** Because both `Serializer` and `Game#moves` read
|
|
43
|
+
`MoveText` / `@moves`, they reflect mutations automatically — there is no
|
|
44
|
+
second source of truth and no sync surface.
|
|
45
|
+
- After any structural mutation, `Node` objects from a previous `#root` call
|
|
46
|
+
are **stale**; call `game.root` again. Mutation methods return the
|
|
47
|
+
freshly-affected `Node`(s) for convenience. This mirrors python-chess, where
|
|
48
|
+
structural mutations invalidate outstanding node references.
|
|
49
|
+
|
|
50
|
+
## Backward compatibility
|
|
51
|
+
|
|
52
|
+
- `Game#moves` continues to return the flat mainline `Array<MoveText>`,
|
|
53
|
+
reflecting any mutations applied through the node API. Its reader/writer
|
|
54
|
+
shapes are unchanged.
|
|
55
|
+
- `MoveText` keeps `notation` / `annotation` / `comment` / `variations`
|
|
56
|
+
(`variations` still an `Array<Array<MoveText>>`). The node API is purely
|
|
57
|
+
additive over it.
|
|
58
|
+
- The 241-example suite stays green; no existing spec is edited for behavior.
|
|
59
|
+
- `Game#to_pgn` byte-identical output is preserved by construction (serializer
|
|
60
|
+
unchanged, reads `MoveText`).
|
|
61
|
+
|
|
62
|
+
## Topology
|
|
63
|
+
|
|
64
|
+
The existing `MoveText.variations` model has variations branching **from the
|
|
65
|
+
position *before* the move** they attach to (confirmed against
|
|
66
|
+
`Serializer#move_token`, which emits `( #{emit_line(variation, fullmove,
|
|
67
|
+
player)} )` from the position before the move, and the parser's
|
|
68
|
+
`san_move_annotated variation_list { result.variations = val[1] }`).
|
|
69
|
+
|
|
70
|
+
So:
|
|
71
|
+
|
|
72
|
+
- A **`Node`** represents a position reached by playing `node.move` from
|
|
73
|
+
`node.parent`'s position.
|
|
74
|
+
- `root` = the game's starting position: `root.move == nil`, `root.parent ==
|
|
75
|
+
nil`, `root.position == game.starting_position`.
|
|
76
|
+
- A node's **children** = all moves playable from that node's position =
|
|
77
|
+
the continuation move (the next `MoveText` in the node's own line) **plus**
|
|
78
|
+
all variation first-moves branching at that same position — recursively
|
|
79
|
+
through nested brackets, because `( A ( B ) )` means both `A` and `B` branch
|
|
80
|
+
at the same point (both are alternatives from that position).
|
|
81
|
+
- Formally, for the continuation move `m = line[index+1]`:
|
|
82
|
+
`first_moves_of(m) = [m] + m.variations.flat_map { |v| first_moves_of(v[0]) }`
|
|
83
|
+
and `node.children = first_moves_of(m)` (each first move wrapped in a child
|
|
84
|
+
`Node` whose line is `m`'s line for `m` itself, or the variation array `v`
|
|
85
|
+
for a variation's first move). When `m` is `nil` (end of line), `children` is
|
|
86
|
+
empty.
|
|
87
|
+
- The **first child** of each node is the mainline continuation; the rest are
|
|
88
|
+
variations in source order. Walking the first-child chain *from the root's
|
|
89
|
+
first child onward* and mapping `&:notation` yields exactly `game.moves`
|
|
90
|
+
(`root.next`, `root.next.next`, … — the root itself is excluded, see
|
|
91
|
+
Navigation notes). This recursion mirrors `Serializer#emit_line` /
|
|
92
|
+
`#move_token` exactly, so the node tree and the serialized output cannot
|
|
93
|
+
disagree.
|
|
94
|
+
|
|
95
|
+
### Why nested-variation flattening is correct
|
|
96
|
+
|
|
97
|
+
`( 1...Nf6 ( 1...d5 ) )` after `1.e4`: `e5.variations = [[Nf6, ...]]` and
|
|
98
|
+
`Nf6.variations = [[d5, ...]]`. Both `Nf6` and `d5` branch from the position
|
|
99
|
+
after `1.e4` (before `Nf6` == before `d5` == after `e4`). So the node for
|
|
100
|
+
"after e4" has children `{e5, Nf6, d5}`. `first_moves_of(e5)` produces exactly
|
|
101
|
+
this set, matching what the serializer emits.
|
|
102
|
+
|
|
103
|
+
## Node API
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
class PGN::Node
|
|
107
|
+
# construction — internal, built by Game#root
|
|
108
|
+
# move: the MoveText played to reach this node (nil for root)
|
|
109
|
+
# line: the Array<MoveText> this node's move lives in
|
|
110
|
+
# (the mainline for mainline nodes, the variation array for
|
|
111
|
+
# variation nodes; nil for root)
|
|
112
|
+
# index: this node's move's index within `line` (-1 for root)
|
|
113
|
+
|
|
114
|
+
attr_reader :move, :parent, :line, :index
|
|
115
|
+
|
|
116
|
+
def root? # node.move.nil? (the root)
|
|
117
|
+
def notation # move.notation (nil for root)
|
|
118
|
+
def annotation # move.annotation
|
|
119
|
+
def comment # move.comment
|
|
120
|
+
def variations # children[1..] (the non-mainline alternatives)
|
|
121
|
+
def main_line # Enumerator<PGN::Node> of the first-child chain
|
|
122
|
+
def next # children.first (mainline continuation)
|
|
123
|
+
def previous # parent (nil for root)
|
|
124
|
+
def children # Array<PGN::Node>, first_moves_of(continuation)
|
|
125
|
+
def [](i) # children[i]
|
|
126
|
+
def position # lazy + cached: parent.position.move(move.notation)
|
|
127
|
+
# (root == game.starting_position); pure-Ruby, no
|
|
128
|
+
# engine dep; raises on illegal SAN like Game#positions
|
|
129
|
+
def promote # move this node one slot toward index 0 in parent.children
|
|
130
|
+
def demote # move one slot toward the end (inverse-swap at index 0:
|
|
131
|
+
# swap mainline down to variation #1 — see semantics)
|
|
132
|
+
def promote_to_main # become parent.children[0]
|
|
133
|
+
def demote_to_last # become parent.children[-1]
|
|
134
|
+
def delete # remove this node (and subtree) from parent
|
|
135
|
+
def add_variation(move_or_moves) # append a variation line to this
|
|
136
|
+
# node's branching point (a non-mainline child)
|
|
137
|
+
def add_main_variation(move_or_moves) # prepend as the new mainline child
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
class PGN::Game
|
|
141
|
+
def root # build and return a fresh PGN::Node tree (root) over @moves
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Navigation notes
|
|
146
|
+
|
|
147
|
+
- `next` returns `children.first` (the mainline continuation) or `nil` at a
|
|
148
|
+
terminal position.
|
|
149
|
+
- `previous` returns `parent`. `root.previous` is `nil`. (The root
|
|
150
|
+
participates as the starting position but is not yielded by `main_line`.)
|
|
151
|
+
- `main_line` yields `root.next`, then `root.next.next`, … — i.e. the nodes
|
|
152
|
+
reached by each mainline move, **excluding the root**. So
|
|
153
|
+
`main_line.map(&:notation) == game.moves.map(&:notation)` and
|
|
154
|
+
`main_line.map(&:position) == game.positions[1..]`. It is an `Enumerator`
|
|
155
|
+
so it composes with the lazy `each_position` style already in the gem.
|
|
156
|
+
The starting position is available via `game.root.position` (==
|
|
157
|
+
`game.starting_position`).
|
|
158
|
+
|
|
159
|
+
### `Node#position`
|
|
160
|
+
|
|
161
|
+
Computed lazily and cached on the node: `node.position = parent.position
|
|
162
|
+
.then { |p| p.move(move.notation) }` (recursive; each ancestor caches, so the
|
|
163
|
+
first access is O(depth) and subsequent accesses are O(1); `root.position ==
|
|
164
|
+
game.starting_position`). Pure Ruby via `PGN::Position#move` — no
|
|
165
|
+
`PGN::Bitboard::Engine` dependency. On an illegal SAN it raises exactly as
|
|
166
|
+
`Game#positions` does today (the replay path is shared). The cache lives on the
|
|
167
|
+
`Node`, so it is dropped implicitly when the node goes stale (after a
|
|
168
|
+
structural mutation you fetch a fresh `#root`).
|
|
169
|
+
|
|
170
|
+
### `add_variation` / `add_main_variation`
|
|
171
|
+
|
|
172
|
+
Accept a single SAN `String` or an `Array<String>` (a sub-line). Internally
|
|
173
|
+
build `MoveText`s with the **same castling `0`→`O` normalization** that
|
|
174
|
+
`Game#moves=` / `standardize_castling` uses (so `O-O` stays canonical), then
|
|
175
|
+
attach the new sub-line to the correct `MoveText.variations`:
|
|
176
|
+
|
|
177
|
+
- The branching point for a node's children is the position **before** the
|
|
178
|
+
node's *next* mainline move `m = line[index+1]`. So `add_variation` appends
|
|
179
|
+
the new sub-line to `m.variations` (creating it if `nil`).
|
|
180
|
+
- If the node is terminal (`m` is `nil`, i.e. end of its line), there is no
|
|
181
|
+
`MoveText` to attach a variation *before*. Two options: (a) append the new
|
|
182
|
+
line as a continuation extension of the line itself — but that changes the
|
|
183
|
+
line, not a variation; (b) raise. We **raise `ArgumentError`** for
|
|
184
|
+
`add_variation` at a terminal node (a variation must branch before an
|
|
185
|
+
existing move; to extend the line, use `add_main_variation` on the last
|
|
186
|
+
node, or push onto `Game#moves` directly). `add_main_variation` at a
|
|
187
|
+
terminal node *extends the line* (appends the moves to `line`), since that
|
|
188
|
+
is the natural "continue the game" operation.
|
|
189
|
+
- `add_main_variation` prepends the new sub-line as the new first child:
|
|
190
|
+
it inserts the new `MoveText`s at the front of `line` at the branching
|
|
191
|
+
index and re-parents the old continuation as a variation of the new first
|
|
192
|
+
move (the inverse of `promote_to_main`'s swap).
|
|
193
|
+
|
|
194
|
+
### promote / demote / delete — `MoveText`-level edits
|
|
195
|
+
|
|
196
|
+
Siblings in `parent.children` are ordered: index 0 = mainline continuation,
|
|
197
|
+
1.. = variations. The catch: in `MoveText` storage, siblings at one branch
|
|
198
|
+
point are not always in a single array. The common case —
|
|
199
|
+
`cont.variations = [V1, V2, V3]` (flat, as in `( V1 ) ( V2 ) ( V3 )`) — keeps
|
|
200
|
+
all siblings in one array. The rare case — `( V1 ( V2 ( V3 ) ) )` — stores
|
|
201
|
+
them nested (`cont.variations = [V1]`, `V1[0].variations = [V2]`, …) even
|
|
202
|
+
though `V1`, `V2`, `V3` all branch at the same point. `first_moves_of`
|
|
203
|
+
flattens both into one sibling list for *reading*; for *reordering* we need
|
|
204
|
+
a single array to edit.
|
|
205
|
+
|
|
206
|
+
**Flatten-on-mutation invariant.** Every structural mutation that reorders
|
|
207
|
+
or removes siblings (`promote`, `demote`, `promote_to_main`, `demote_to_last`,
|
|
208
|
+
`delete`) first **normalizes the affected branching point to flat sibling
|
|
209
|
+
storage**, then performs the edit on that flat `variations` array.
|
|
210
|
+
`add_variation` / `add_main_variation` also normalize so the sibling set stays
|
|
211
|
+
flat and uniform. After normalization, every sibling at the point is a direct
|
|
212
|
+
element of one `variations` array (the continuation's, or the new
|
|
213
|
+
continuation's after a swap).
|
|
214
|
+
|
|
215
|
+
Normalization is localized to one branch point: it hoists every variation
|
|
216
|
+
first-move that branches at that point (recursively through nested brackets)
|
|
217
|
+
into the continuation's `variations` array, and clears those first-moves'
|
|
218
|
+
`variations` (the hoisted lines now live as separate flat siblings). The
|
|
219
|
+
*internal* structure of each variation line (its tail moves and their own
|
|
220
|
+
deeper variations) is untouched — only the nesting *at this branch point* is
|
|
221
|
+
flattened. This is a documented mutation side-effect (a same-point-nested
|
|
222
|
+
`( V1 ( V2 ) )` becomes `( V1 ) ( V2 )` after any reorder at that point); it
|
|
223
|
+
is idempotent and the round-trip gate (below) asserts the mutated structure
|
|
224
|
+
re-parses to itself. All current fixtures are already flat at every branch
|
|
225
|
+
point, so for them normalization is a no-op.
|
|
226
|
+
|
|
227
|
+
**Normalization algorithm** at a branching point whose continuation is `cont`
|
|
228
|
+
(`cont = line[i]`):
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
def normalize(cont):
|
|
232
|
+
lines = [] # flat sibling variation lines, DFS order
|
|
233
|
+
collect = ->(m) {
|
|
234
|
+
(m.variations || []).each do |v| # v branches before m == at this point
|
|
235
|
+
lines << v
|
|
236
|
+
collect.call(v[0]) # nested same-point variations hoist too
|
|
237
|
+
end
|
|
238
|
+
}
|
|
239
|
+
collect.call(cont)
|
|
240
|
+
lines.each { |v| v[0].variations = (v[0].variations || []).clear }
|
|
241
|
+
# NB: each v[0]'s at-this-point variations are now hoisted into `lines`;
|
|
242
|
+
# v[0]'s tail moves (v[1..]) and their deeper variations are untouched.
|
|
243
|
+
cont.variations = lines
|
|
244
|
+
lines
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
After normalization `cont.variations = [V1, V2, …]` is a flat array of
|
|
249
|
+
variation lines, in the same order `first_moves_of(cont)` yields. Reorders
|
|
250
|
+
are then plain array edits:
|
|
251
|
+
|
|
252
|
+
**`promote`** — move this node's line one slot toward index 0 in
|
|
253
|
+
`cont.variations` (swap with the previous element); no-op if it is already
|
|
254
|
+
the continuation (index 0) or the first variation (index 1).
|
|
255
|
+
|
|
256
|
+
**`demote`** — move one slot toward the end (swap with the next element).
|
|
257
|
+
At index 0 (the node *is* the continuation `cont`), `demote` is the
|
|
258
|
+
**inverse swap**: the first variation `V1 = cont.variations[0]` is promoted
|
|
259
|
+
to continuation (see `promote_to_main` applied to `V1`), so the old
|
|
260
|
+
mainline drops to variation #1. At the last index, `demote` is a no-op.
|
|
261
|
+
|
|
262
|
+
**`promote_to_main`** — promote variation `Vk = [vk0, *vk_tail]` (currently
|
|
263
|
+
`cont.variations[k]`) to the new mainline at this point:
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
flat = cont.variations # already normalized: [V1, …, Vk, …]
|
|
267
|
+
vk = flat.delete_at(k) # [vk0, *vk_tail]
|
|
268
|
+
old_tail = line[i+1..] # the old mainline tail (Array<MoveText>)
|
|
269
|
+
line[i] = vk0
|
|
270
|
+
line[i+1..] = vk_tail # V becomes the mainline
|
|
271
|
+
cont.variations = [] # old continuation no longer owns these
|
|
272
|
+
vk0.variations = [[cont, *old_tail], *flat] # old mainline → variation, others stay
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**`demote_to_last`** — repeat `demote` until the node is the last sibling
|
|
276
|
+
(equivalently: move its line to the end of `cont.variations`; if it was the
|
|
277
|
+
continuation, that is one `promote_to_main`-of-the-next-variation plus a
|
|
278
|
+
move-to-end — simplest implemented as repeated `demote`).
|
|
279
|
+
|
|
280
|
+
**`delete`** — after normalization:
|
|
281
|
+
- if the node is a variation `Vk`: `cont.variations.delete_at(k)`.
|
|
282
|
+
- if the node is the continuation `cont`: remove it from the line. If
|
|
283
|
+
`cont.variations` is now non-empty, the first variation `V1` becomes the new
|
|
284
|
+
continuation (`line[i] = V1[0]`, `line[i+1..] = V1[1..]`, `V1[0].variations =
|
|
285
|
+
cont.variations[1..]`); otherwise the line is truncated at `i`
|
|
286
|
+
(`line[i..] = []`).
|
|
287
|
+
|
|
288
|
+
**`add_variation(move_or_moves)`** — normalize, then append the new sub-line
|
|
289
|
+
to `cont.variations`.
|
|
290
|
+
|
|
291
|
+
**`add_main_variation(move_or_moves)`** — if the node is terminal
|
|
292
|
+
(`cont`/continuation is `nil`), extend `line` with the new `MoveText`s (the
|
|
293
|
+
natural "continue the game" operation). Otherwise normalize, build the new
|
|
294
|
+
sub-line `N = [n0, *n_tail]`, insert it as the new continuation
|
|
295
|
+
(`line[i] = n0`, `line[i+1..] = n_tail`) and set
|
|
296
|
+
`n0.variations = [[cont, *old_tail], *cont.variations]` (the inverse of
|
|
297
|
+
`promote_to_main`).
|
|
298
|
+
|
|
299
|
+
### After mutation
|
|
300
|
+
|
|
301
|
+
- `Game#moves` (the `@moves` array) is mutated in place for mainline swaps, so
|
|
302
|
+
its reader reflects the new mainline. `MoveText.variations` arrays are
|
|
303
|
+
replaced/augmented for variation swaps.
|
|
304
|
+
- The serializer reads the same `MoveText`/`@moves`, so `to_pgn` reflects the
|
|
305
|
+
mutation. The round-trip gate (below) asserts this.
|
|
306
|
+
- Node objects from the prior `#root` are stale; re-fetch via `game.root`.
|
|
307
|
+
Mutation methods return the affected `Node`(s) from a freshly-built tree
|
|
308
|
+
so callers can chain without an explicit re-`root`.
|
|
309
|
+
|
|
310
|
+
## Files
|
|
311
|
+
|
|
312
|
+
- `lib/pgn/node.rb` — new. `PGN::Node`.
|
|
313
|
+
- `lib/pgn/game.rb` — add `#root` (builds the tree) and require `pgn/node`.
|
|
314
|
+
No change to existing methods' behavior.
|
|
315
|
+
- `lib/pgn.rb` — require `pgn/node` (if not pulled in via `pgn/game`).
|
|
316
|
+
- `spec/node_spec.rb` — new.
|
|
317
|
+
- `spec/game_spec.rb` — extend the round-trip test to exercise mutations
|
|
318
|
+
(additive; existing assertions untouched).
|
|
319
|
+
- No change to `serializer.rb`, `pgn_parser.y`, `move.rb`, `move_text`.
|
|
320
|
+
|
|
321
|
+
## Testing
|
|
322
|
+
|
|
323
|
+
### New `spec/node_spec.rb`
|
|
324
|
+
|
|
325
|
+
- **Shape:** for each fixture game, `game.root.main_line.map(&:notation) ==
|
|
326
|
+
game.moves.map(&:notation)`.
|
|
327
|
+
- **Positions:** `game.root.main_line.map(&:position) == game.positions[1..]`
|
|
328
|
+
(root excluded). For variation nodes, assert against a hand-replayed FEN
|
|
329
|
+
on `spec/pgn_files/variations.pgn` (e.g. the `Nc3` variation node's
|
|
330
|
+
position == the FEN after `1. e4 e5 2. Nc3`).
|
|
331
|
+
- **Children/variation count:** assert `node.children.size` and
|
|
332
|
+
`node.variations.size` against fixture structure (e.g. the node after
|
|
333
|
+
`1.e4 e5` has 3 children: `Nf3`, `Nc3`, `f4`).
|
|
334
|
+
- **`add_variation`:** append a SAN, assert the new `MoveText` appears in the
|
|
335
|
+
target `MoveText.variations`, and `game.to_pgn` re-parses with the variation
|
|
336
|
+
present.
|
|
337
|
+
- **`add_variation` at terminal node raises `ArgumentError`**; at a
|
|
338
|
+
non-terminal node appends correctly.
|
|
339
|
+
- **`promote_to_main` / `promote` / `demote` / `demote_to_last` / `delete`:**
|
|
340
|
+
assert the resulting `MoveText` structure (notations + variation layout)
|
|
341
|
+
and that `game.to_pgn` round-trips.
|
|
342
|
+
- **`demote` at index 0 is the inverse swap** (covered by a dedicated test).
|
|
343
|
+
- **Flatten-on-mutation:** a synthetic `( V1 ( V2 ) )` same-point-nested
|
|
344
|
+
fixture, after a `promote`, re-serializes to flat `( V1 ) ( V2 )` and
|
|
345
|
+
re-parses idempotently.
|
|
346
|
+
|
|
347
|
+
### Extended round-trip gate (`spec/game_spec.rb`)
|
|
348
|
+
|
|
349
|
+
Add an additive block that, for each non-`non_round_trip` fixture, after
|
|
350
|
+
parsing: builds `game.root`, performs a scripted `promote`+`demote`+`add_variation`
|
|
351
|
+
on the first branching point (skip fixtures with no variations), re-serializes,
|
|
352
|
+
re-parses, and asserts `expect_moves_equal` still holds against the mutated
|
|
353
|
+
structure. Existing assertions unchanged.
|
|
354
|
+
|
|
355
|
+
### Existing suite
|
|
356
|
+
|
|
357
|
+
Unchanged; 241 examples stay green.
|
|
358
|
+
|
|
359
|
+
## Risks & mitigations
|
|
360
|
+
|
|
361
|
+
- **Topology bug → serializer disagreement.** Mitigation: the node builder
|
|
362
|
+
mirrors `Serializer#emit_line`/`#move_token` recursion, and a spec asserts
|
|
363
|
+
`main_line` ↔ `Game#moves` equivalence on every fixture.
|
|
364
|
+
- **Stale-node misuse after mutation.** Mitigation: documented; mutation
|
|
365
|
+
methods return fresh nodes from a re-built tree. We do **not** add a runtime
|
|
366
|
+
stale-check (would need per-mutation bookkeeping on every `Node`); the
|
|
367
|
+
round-trip gate catches structural mistakes, and the documentation makes the
|
|
368
|
+
"re-`root` after mutation" contract explicit.
|
|
369
|
+
- **`promote`/`demote` on deeply nested variations.** Mitigation: the
|
|
370
|
+
flatten-on-mutation invariant reduces every reorder to a flat-array edit;
|
|
371
|
+
dedicated tests use `spec/pgn_files/variations.pgn` (flat at every branch
|
|
372
|
+
point) and a synthetic same-point-nested `( V1 ( V2 ) )` fixture to cover
|
|
373
|
+
the normalization path.
|
|
374
|
+
- **Backward-compat drift in `Game#moves`.** Mitigation: `@moves` is mutated
|
|
375
|
+
in place (not replaced with a new object type); existing specs compare
|
|
376
|
+
`map(&:notation)` and `MoveText` structure — all still hold.
|
|
377
|
+
|
|
378
|
+
## Decisions settled during self-review
|
|
379
|
+
|
|
380
|
+
- `main_line` excludes the root (yields `root.next`, `root.next.next`, …) so
|
|
381
|
+
it lines up 1:1 with `game.moves` and `game.positions[1..]`.
|
|
382
|
+
- `previous` is simply `parent` everywhere; `root.previous` is `nil`.
|
|
383
|
+
- Structural mutations normalize the affected branching point to flat
|
|
384
|
+
sibling storage before editing (see "Flatten-on-mutation invariant"). This
|
|
385
|
+
resolves the nested-variation reorder case that the first draft hand-waved.
|
|
386
|
+
- No runtime stale-check on `Node`; the "re-`root` after mutation" contract is
|
|
387
|
+
documented and enforced by the round-trip gate, not by per-node bookkeeping.
|
data/lib/pgn/attack.rb
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGN
|
|
4
|
+
# {PGN::Attack} is the single source of truth for square-attack queries on
|
|
5
|
+
# a {PGN::Board}. It factors the private attack logic that {PGN::Notation}
|
|
6
|
+
# previously duplicated, so {PGN::Position} (and other consumers) can ask
|
|
7
|
+
# "is this square attacked?" and "which squares attack this square?".
|
|
8
|
+
#
|
|
9
|
+
# Squares are 0x88 indices (see {PGN::Board}); on-board when
|
|
10
|
+
# `(idx & 0x88).zero?`. `color` is 'w' or 'b'.
|
|
11
|
+
module Attack
|
|
12
|
+
BISHOP_DIRS = [-15, 15, -17, 17].freeze
|
|
13
|
+
ROOK_DIRS = [-1, 1, -16, 16].freeze
|
|
14
|
+
|
|
15
|
+
# The 0x88 index of the `color` king on +board+, or nil if absent.
|
|
16
|
+
def self.king_idx(board, color)
|
|
17
|
+
king = color == 'w' ? 'K' : 'k'
|
|
18
|
+
(0...128).each do |idx|
|
|
19
|
+
next if idx.anybits?(0x88)
|
|
20
|
+
|
|
21
|
+
return idx if board.at_index(idx) == king
|
|
22
|
+
end
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Whether +target+ (a 0x88 index) is attacked by any `color` piece.
|
|
27
|
+
def self.attacked?(board, target, color)
|
|
28
|
+
attackers(board, target, color).any?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The algebraic squares of every `color` piece on +board+ that attacks
|
|
32
|
+
# +target+ (a 0x88 index), in no particular order.
|
|
33
|
+
def self.attackers(board, target, color)
|
|
34
|
+
pawn_attackers(board, target, color) +
|
|
35
|
+
knight_attackers(board, target, color) +
|
|
36
|
+
king_attackers(board, target, color) +
|
|
37
|
+
slider_attackers(board, target, color)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def pawn_attackers(board, target, color)
|
|
44
|
+
offs = color == 'w' ? [-15, -17] : [15, 17]
|
|
45
|
+
pawn = color == 'w' ? 'P' : 'p'
|
|
46
|
+
offs.each_with_object([]) do |off, a|
|
|
47
|
+
i = target + off
|
|
48
|
+
a << board.square_name(i) if i.nobits?(0x88) && board.at_index(i) == pawn
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def knight_attackers(board, target, color)
|
|
53
|
+
knight = color == 'w' ? 'N' : 'n'
|
|
54
|
+
Board::KNIGHT_ATTACKS[target].each_with_object([]) do |i, a|
|
|
55
|
+
a << board.square_name(i) if board.at_index(i) == knight
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def king_attackers(board, target, color)
|
|
60
|
+
king = color == 'w' ? 'K' : 'k'
|
|
61
|
+
Board::KING_ATTACKS[target].each_with_object([]) do |i, a|
|
|
62
|
+
a << board.square_name(i) if board.at_index(i) == king
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def slider_attackers(board, target, color)
|
|
67
|
+
bishop = color == 'w' ? 'B' : 'b'
|
|
68
|
+
rook = color == 'w' ? 'R' : 'r'
|
|
69
|
+
queen = color == 'w' ? 'Q' : 'q'
|
|
70
|
+
squares = []
|
|
71
|
+
ray_attackers(board, target, BISHOP_DIRS) do |piece, sq|
|
|
72
|
+
squares << sq if piece == bishop || piece == queen
|
|
73
|
+
end
|
|
74
|
+
ray_attackers(board, target, ROOK_DIRS) do |piece, sq|
|
|
75
|
+
squares << sq if piece == rook || piece == queen
|
|
76
|
+
end
|
|
77
|
+
squares
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Walk each ray from +target+; yield the first piece hit on that ray
|
|
81
|
+
# along with its square.
|
|
82
|
+
def ray_attackers(board, target, dirs)
|
|
83
|
+
dirs.each do |off|
|
|
84
|
+
i = target + off
|
|
85
|
+
while i.nobits?(0x88)
|
|
86
|
+
piece = board.at_index(i)
|
|
87
|
+
if piece
|
|
88
|
+
yield(piece, board.square_name(i))
|
|
89
|
+
break
|
|
90
|
+
end
|
|
91
|
+
i += off
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/pgn/epd.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PGN
|
|
4
|
+
# {PGN::EPD} translates between strings in Extended Position Description
|
|
5
|
+
# and a {PGN::Position}. EPD is the FEN-like position format used by
|
|
6
|
+
# {http://www.chessprogramming.org/Extended_Position_Description EPD tools}:
|
|
7
|
+
# it shares FEN's first four fields (piece placement, side to move,
|
|
8
|
+
# castling availability, en passant target square) and then carries a
|
|
9
|
+
# trailing list of operations (`ops`) instead of the halfmove/fullmove
|
|
10
|
+
# counters.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# PGN::EPD.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -')
|
|
14
|
+
# PGN::FEN.start.to_epd #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -"
|
|
15
|
+
#
|
|
16
|
+
class EPD
|
|
17
|
+
attr_accessor :board, :active, :ops
|
|
18
|
+
attr_reader :castling, :en_passant
|
|
19
|
+
|
|
20
|
+
# @param epd_string [String] an EPD string: four FEN fields followed by
|
|
21
|
+
# zero or more operation fields (kept verbatim as {#ops}).
|
|
22
|
+
#
|
|
23
|
+
def initialize(epd_string = nil)
|
|
24
|
+
return unless epd_string
|
|
25
|
+
|
|
26
|
+
fields = epd_string.split(' ', 5)
|
|
27
|
+
self.board_string = fields[0]
|
|
28
|
+
self.active = fields[1]
|
|
29
|
+
self.castling = fields[2]
|
|
30
|
+
self.en_passant = fields[3]
|
|
31
|
+
self.ops = fields[4]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def castling=(val)
|
|
35
|
+
@castling = val.nil? || val.empty? ? '-' : val
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def en_passant=(val)
|
|
39
|
+
@en_passant = val.nil? ? '-' : val
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param board_fen [String] the FEN/EPD representation of the board
|
|
43
|
+
#
|
|
44
|
+
def board_string=(board_fen)
|
|
45
|
+
squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
|
|
46
|
+
.split('/')
|
|
47
|
+
.map(&:chars)
|
|
48
|
+
.map { |row| row.map { |e| e == '_' ? nil : e } }
|
|
49
|
+
.reverse
|
|
50
|
+
.transpose
|
|
51
|
+
self.board = PGN::Board.new(squares)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String] the EPD board-string portion
|
|
55
|
+
#
|
|
56
|
+
def board_string
|
|
57
|
+
board.fen_board_string
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [PGN::Position] a {PGN::Position} for this EPD. Halfmove and
|
|
61
|
+
# fullmove default to 0 and 1 (EPD does not carry them).
|
|
62
|
+
#
|
|
63
|
+
def to_position
|
|
64
|
+
player = active == 'w' ? :white : :black
|
|
65
|
+
castling_rights = castling.chars - ['-']
|
|
66
|
+
ep = en_passant == '-' ? nil : en_passant
|
|
67
|
+
|
|
68
|
+
PGN::Position.new(board, player, castling_rights, ep, 0, 1)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [String] the EPD string (four fields, then +ops+ if present)
|
|
72
|
+
#
|
|
73
|
+
def to_s
|
|
74
|
+
[board_string, active, castling, en_passant, ops].compact.reject(&:empty?).join(' ')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inspect
|
|
78
|
+
to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/pgn/fen.rb
CHANGED
|
@@ -111,7 +111,7 @@ module PGN
|
|
|
111
111
|
def to_position
|
|
112
112
|
player = active == 'w' ? :white : :black
|
|
113
113
|
castling = self.castling.chars - ['-']
|
|
114
|
-
en_passant =
|
|
114
|
+
en_passant = self.en_passant == '-' ? nil : self.en_passant
|
|
115
115
|
|
|
116
116
|
PGN::Position.new(
|
|
117
117
|
board,
|
|
@@ -123,6 +123,13 @@ module PGN
|
|
|
123
123
|
)
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
# @return [String] the EPD string for this position (the first four FEN
|
|
127
|
+
# fields, dropping the halfmove/fullmove counters)
|
|
128
|
+
#
|
|
129
|
+
def to_epd
|
|
130
|
+
PGN::EPD.new("#{board_string} #{active} #{castling} #{en_passant}").to_s
|
|
131
|
+
end
|
|
132
|
+
|
|
126
133
|
# @return [String] the FEN string
|
|
127
134
|
# @example
|
|
128
135
|
# PGN::FEN.start.to_s #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|