pgn2 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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.
@@ -1,5 +1,3 @@
1
- use crate::moves::{Move, MoveList};
2
-
3
1
  /// A chess position backed by `chessie::Game`. Holds no chess logic of
4
2
  /// its own; every operation delegates. `Copy` because `chessie::Game`
5
3
  /// is `Copy`; `Default` because the magnus `Engine` wraps it in a
@@ -20,13 +18,7 @@ impl Board {
20
18
  self.game.perft(depth as usize)
21
19
  }
22
20
 
23
- pub fn legal_moves(&self) -> MoveList {
24
- let moves: Vec<Move> = self
25
- .game
26
- .get_legal_moves()
27
- .into_iter()
28
- .map(Move::from_chessie)
29
- .collect();
30
- MoveList(moves)
21
+ pub fn legal_moves(&self) -> chessie::MoveList {
22
+ self.game.get_legal_moves()
31
23
  }
32
24
  }
@@ -5,8 +5,6 @@
5
5
  //! Ruby in the loop.
6
6
 
7
7
  pub mod board;
8
- pub mod moves;
9
8
  pub mod perft;
10
9
 
11
10
  pub use board::Board;
12
- pub use moves::{Move, MoveList};
@@ -32,10 +32,10 @@ impl Engine {
32
32
  }
33
33
 
34
34
  fn legal_p(&self, uci: String) -> bool {
35
- match pgn2_bitboard::moves::uci_parse(&uci) {
36
- Some(parsed) => self.0.borrow().legal_moves().iter().any(|m| m.same_target(parsed)),
37
- None => false,
38
- }
35
+ // `chessie::Move`'s `PartialEq<str>` compares by `to_uci()`, so this
36
+ // matches on castle/promotion notation the same way `legal_moves_ruby`
37
+ // renders it — no separate UCI parser needed.
38
+ self.0.borrow().legal_moves().iter().any(|m| m == &uci)
39
39
  }
40
40
  }
41
41
 
@@ -49,6 +49,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
49
49
  engine.define_method("initialize", method!(Engine::initialize, 1))?;
50
50
  engine.define_method("perft", method!(Engine::perft, 1))?;
51
51
  engine.define_method("legal_moves", method!(Engine::legal_moves_ruby, 0))?;
52
- engine.define_method("legal?", method!(Engine::legal_p, 1))?;;
52
+ engine.define_method("legal?", method!(Engine::legal_p, 1))?;
53
53
  Ok(())
54
54
  }
data/lib/pgn/attack.rb ADDED
@@ -0,0 +1,107 @@
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 = piece_letter('K', color)
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
+ # Checked (and short-circuited) piece type by piece type, cheapest first,
28
+ # so a hit skips the pricier ray-walk sliders scan entirely.
29
+ def self.attacked?(board, target, color)
30
+ pawn_attackers(board, target, color).any? ||
31
+ knight_attackers(board, target, color).any? ||
32
+ king_attackers(board, target, color).any? ||
33
+ slider_attackers(board, target, color).any?
34
+ end
35
+
36
+ # The algebraic squares of every `color` piece on +board+ that attacks
37
+ # +target+ (a 0x88 index), in no particular order.
38
+ def self.attackers(board, target, color)
39
+ pawn_attackers(board, target, color) +
40
+ knight_attackers(board, target, color) +
41
+ king_attackers(board, target, color) +
42
+ slider_attackers(board, target, color)
43
+ end
44
+
45
+ class << self
46
+ private
47
+
48
+ # 'w' -> the uppercase letter, 'b' -> the lowercase letter.
49
+ def piece_letter(letter, color)
50
+ color == 'w' ? letter : letter.downcase
51
+ end
52
+
53
+ def pawn_attackers(board, target, color)
54
+ offs = color == 'w' ? [-15, -17] : [15, 17]
55
+ pawn = piece_letter('P', color)
56
+ offs.each_with_object([]) do |off, a|
57
+ i = target + off
58
+ a << board.square_name(i) if i.nobits?(0x88) && board.at_index(i) == pawn
59
+ end
60
+ end
61
+
62
+ def knight_attackers(board, target, color)
63
+ knight = piece_letter('N', color)
64
+ Board::KNIGHT_ATTACKS[target].each_with_object([]) do |i, a|
65
+ a << board.square_name(i) if board.at_index(i) == knight
66
+ end
67
+ end
68
+
69
+ def king_attackers(board, target, color)
70
+ king = piece_letter('K', color)
71
+ Board::KING_ATTACKS[target].each_with_object([]) do |i, a|
72
+ a << board.square_name(i) if board.at_index(i) == king
73
+ end
74
+ end
75
+
76
+ def slider_attackers(board, target, color)
77
+ bishop = piece_letter('B', color)
78
+ rook = piece_letter('R', color)
79
+ queen = piece_letter('Q', color)
80
+ squares = []
81
+ ray_attackers(board, target, BISHOP_DIRS) do |piece, sq|
82
+ squares << sq if piece == bishop || piece == queen
83
+ end
84
+ ray_attackers(board, target, ROOK_DIRS) do |piece, sq|
85
+ squares << sq if piece == rook || piece == queen
86
+ end
87
+ squares
88
+ end
89
+
90
+ # Walk each ray from +target+; yield the first piece hit on that ray
91
+ # along with its square.
92
+ def ray_attackers(board, target, dirs)
93
+ dirs.each do |off|
94
+ i = target + off
95
+ while i.nobits?(0x88)
96
+ piece = board.at_index(i)
97
+ if piece
98
+ yield(piece, board.square_name(i))
99
+ break
100
+ end
101
+ i += off
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
data/lib/pgn/epd.rb ADDED
@@ -0,0 +1,54 @@
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
+ include PositionFields
18
+
19
+ attr_accessor :board, :active, :ops
20
+ attr_reader :castling, :en_passant
21
+
22
+ # @param epd_string [String] an EPD string: four FEN fields followed by
23
+ # zero or more operation fields (kept verbatim as {#ops}).
24
+ #
25
+ def initialize(epd_string = nil)
26
+ return unless epd_string
27
+
28
+ fields = epd_string.split(' ', 5)
29
+ self.board_string = fields[0]
30
+ self.active = fields[1]
31
+ self.castling = fields[2]
32
+ self.en_passant = fields[3]
33
+ self.ops = fields[4]
34
+ end
35
+
36
+ # @return [PGN::Position] a {PGN::Position} for this EPD. Halfmove and
37
+ # fullmove default to 0 and 1 (EPD does not carry them).
38
+ #
39
+ def to_position
40
+ player, castling_rights, ep = position_fields
41
+ PGN::Position.new(board, player, castling_rights, ep, 0, 1)
42
+ end
43
+
44
+ # @return [String] the EPD string (four fields, then +ops+ if present)
45
+ #
46
+ def to_s
47
+ [board_string, active, castling, en_passant, ops].compact.reject(&:empty?).join(' ')
48
+ end
49
+
50
+ def inspect
51
+ to_s
52
+ end
53
+ end
54
+ end