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.
@@ -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 = nil if self.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"