pgn2 1.3.0 → 1.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33036a2920baf0e170efc5e1c387c2a53ae91fe19ad19cf745d4d6f049c9fe20
4
- data.tar.gz: 93ccbe0289a72f4af5fb8367a4d1f5bdc0c1d5eb77f1a65e3381b7d17f5d3822
3
+ metadata.gz: da98f302d6785b75581d53fbe6dab4b7ccad9f5514f7ad278be12a540633d572
4
+ data.tar.gz: 9b22f32a2b44bd06cfd8d29c82f9b4af8c1c4687ed9650df33c4d5fdc76dd49e
5
5
  SHA512:
6
- metadata.gz: 25ed517772982909d896b4b372308d235574db54aa44f3ff98f5201f9055120fc64d19fcd634c6676f7bfb66cf5e4de483a2a9ae13293b3162781339434bfac1
7
- data.tar.gz: bf2a935f6aa4fedffb5976b0febc5eb351354165275d8b248eac99f7a713d74cf6acb3cb9e0be51c6030a55481663173ab5c9008cf5dfa67904bd09ac4e271f5
6
+ metadata.gz: 0c99f7034728254221189f9af053bc6c57938d24c96d83d8dacce7ad78cf2bc8b23de3b28a497c9b750b12f63dd17a9ca6ed65b293c5f8f0188d4f79b188e5d2
7
+ data.tar.gz: 437fbf1d534aa15578bef06c4ca5020116f6b2385d81c886104f69bb8db0124d7e220b3d566a22f8df4e93180929519891ba71279db21e0e2f541f1b6f0f8892
data/CHANGELOG.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0 (2026-08-13)
4
+
5
+ ### Summary
6
+
7
+ Clarity refactor on the 0x88 hot path, plus a small parser perf win: no
8
+ public API changes, no behavior change; serialized PGN/FEN output stays
9
+ byte-identical; all 201 specs green.
10
+
11
+ ### Changed
12
+ - **`PGN::Board`**: promoted the on-board bitmask test and square-name
13
+ lookup to public `on_board?(idx)` / `square_name(idx)` methods,
14
+ replacing private duplicates of the same logic in `MoveCalculator`.
15
+ Added a named `Board.from_cells(cells)` factory for building a board
16
+ directly from a 128-cell 0x88 array; `#dup` (called every move) now
17
+ routes through it instead of reaching around the constructor via
18
+ `Board.allocate` + `instance_variable_set` directly. Performance is
19
+ identical — same allocate + one ivar set — just a documented factory
20
+ instead of a reflective one-off.
21
+ - **`PGN::MoveCalculator`**: calls `board.on_board?`/`board.square_name`
22
+ instead of its own private copies; named the castling-table square
23
+ literals (`C1`..`G8`) instead of raw 0x88 integers in `CASTLING`, for
24
+ readability. `#board`/`#move` are now `attr_reader` instead of
25
+ `attr_accessor` — there was no external writer, and the `@dest_idx`
26
+ memo already silently relied on both never changing after
27
+ `#initialize`; dropping the setters enforces that invariant in the
28
+ type instead of by convention.
29
+ - **`PGN::Lexer`**: dropped the per-token `@line` counter (one
30
+ `str.count("\n")` via `advance_line` on every `next_token`/
31
+ `next_token_pair` call) in favor of a lazy `line_at(off)`, computed
32
+ only when a line number is actually needed (error messages, the spec
33
+ `tokens` helper) — the parser itself never reads it. Removes a
34
+ `str.count("\n")` call from the parse hot path (~6% of parse CPU for
35
+ a value that was never read).
36
+
37
+ ## 1.4.0 (2026-08-13)
38
+
39
+ ### Summary
40
+
41
+ New `PGN::Notation` module that *generates* Standard Algebraic Notation
42
+ (SAN) for a single coordinate move (origin square, destination square,
43
+ optional promotion) given the position before the move. The rest of the
44
+ gem only *parses* SAN; this is the reverse direction, needed to render
45
+ moves stored as coordinates (e.g. `e2`-`e4`) in standard chess notation.
46
+
47
+ ### Added
48
+ - `PGN::Notation.san(position, from, to, promotion = nil)` and the
49
+ convenience `PGN::Notation.san_from_fen(fen, from, to, promotion = nil)`.
50
+ Builds full SAN: piece letter, capture (`x`), castling (`O-O`/`O-O-O`),
51
+ pawn capture file (`exd5`), promotion (`=Q`), legal-move disambiguation
52
+ (file / rank / full square, respecting pins), and check (`+`) / checkmate
53
+ (`#`) suffixes. Checkmate detection drives full legal-move generation for
54
+ the side to move (the gem's first move generator). Raise `ArgumentError`
55
+ if the origin square is empty.
56
+ - New `spec/notation_spec.rb`; all 201 specs green. A round-trip harness over
57
+ every parseable fixture reproduces the original SAN of all 277 moves
58
+ exactly (including disambiguation and `+`/`#` suffixes).
59
+
60
+ ### Changed (parser performance)
61
+ - `PGN::Lexer#scan_one` now dispatches on the leading byte of the next
62
+ token via a frozen `BYTE_DISPATCH` table (with an `ALL_RULES` fallback),
63
+ trying only the 1-2 rules that can match that byte instead of walking all
64
+ nine rules in order. Profiling had `StringScanner#scan` at ~23% of parse
65
+ CPU and the rule loop ~38% inclusive; the dispatch nearly halves scan time.
66
+ - `PGN::PgnParser#next_token` mutates the lexer's `[type, value]` pair in
67
+ place rather than allocating a second translated pair — one array per token
68
+ is the Racc floor.
69
+ - Net (500 immortal games): parse-only throughput +25% (305 → 203 ms/i),
70
+ parse allocations −17% (347037 → 288537 objects / 17977414 → 15640374
71
+ bytes), parse+replay allocations −7% (831530 → 773030 objects). Serialized
72
+ PGN/FEN output stays byte-identical; all 201 specs green.
73
+
3
74
  ## 1.3.0 (2026-08-13)
4
75
 
5
76
  ### Summary
data/README.md CHANGED
@@ -122,6 +122,29 @@ _ _ _ ♙ _ _ _ _
122
122
  => r1bk3r/p2pBpNp/n4n2/1p1NP2P/6P1/3P4/P1P1K3/q5b1 b - - 1 22
123
123
  ```
124
124
 
125
+ ### Generating SAN from coordinates
126
+
127
+ {PGN::Notation} is the reverse of {PGN::Move}: it *builds* Standard
128
+ Algebraic Notation for a coordinate move (origin square, destination square,
129
+ optional promotion) given the position before the move. Use it to render
130
+ moves stored as coordinates in standard chess notation.
131
+
132
+ ```
133
+ > PGN::Notation.san(PGN::Position.start, "g1", "f3")
134
+ => "Nf3"
135
+
136
+ > PGN::Notation.san_from_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "e2", "e4")
137
+ => "e4"
138
+
139
+ > fen = "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1"
140
+ > PGN::Notation.san_from_fen(fen, "e1", "g1")
141
+ => "O-O"
142
+ ```
143
+
144
+ It handles captures, en passant, promotions, legal-move disambiguation
145
+ (file / rank / full square, respecting pins), and check (`+`) / checkmate
146
+ (`#`) suffixes.
147
+
125
148
  ## Benchmarks
126
149
 
127
150
  A reproducible profiling harness lives in `bench/`. It measures the
@@ -181,12 +204,12 @@ Parser — 500 immortal games (`bench/profile_parse.rb`):
181
204
 
182
205
  | Metric | original `pgn` | pgn2 | Δ |
183
206
  |---|---:|---:|---:|
184
- | Parse-only allocations (objects) | 1248065 | 347037 | -901028 (-72.2%) |
185
- | Parse-only allocations (bytes) | 120370470 | 17977414 | -102393056 (-85.1%) |
186
- | Parse + replay allocations (objects) | 3778073 | 831530 | -2946543 (-78.0%) |
187
- | Parse + replay allocations (bytes) | 249570048 | 47966112 | -201603936 (-80.8%) |
188
- | Parse-only throughput | 1461 ms/i | 305 ms/i | ~4.8x faster |
189
- | Parse + replay throughput | 1938 ms/i | 534 ms/i | ~3.6x faster |
207
+ | Parse-only allocations (objects) | 1248065 | 288537 | -959528 (-76.9%) |
208
+ | Parse-only allocations (bytes) | 120370470 | 15640374 | -104730096 (-87.0%) |
209
+ | Parse + replay allocations (objects) | 3778073 | 773030 | -3005043 (-79.5%) |
210
+ | Parse + replay allocations (bytes) | 249570048 | 45626128 | -203943920 (-81.7%) |
211
+ | Parse-only throughput | 1461 ms/i | 203 ms/i | ~7.2x faster |
212
+ | Parse + replay throughput | 1938 ms/i | 484 ms/i | ~4.0x faster |
190
213
 
191
214
  What changed to get there:
192
215
 
@@ -239,9 +262,19 @@ What changed to get there:
239
262
  must clone the index every move and every move pays maintenance that pawns
240
263
  (the common case, geometry-fixed origins) can't use — so it was rejected
241
264
  and reverted. The 0x88 board alone is the winner.
265
+ 15. `PGN::Lexer#scan_one` — byte-dispatch: the leading byte of the next
266
+ token selects the 1-2 `RULES` that can possibly match it (a frozen
267
+ `BYTE_DISPATCH` table; `ALL_RULES` fallback) instead of walking all nine
268
+ rules in order. `StringScanner#scan` was ~23% of parse CPU and the rule
269
+ loop ~38% inclusive; the dispatch nearly halves scan time. `PgnParser#
270
+ next_token` now mutates the lexer's `[type, value]` pair in place instead
271
+ of allocating a second translated pair (one array per token is the Racc
272
+ floor). Parse-only throughput +25% (305 → 203 ms/i), parse allocations
273
+ −17% (347037 → 288537 objects / 17977414 → 15640374 bytes for 500 games).
274
+ Output byte-identical.
242
275
 
243
276
  Public output (FEN, PGN) is byte-identical to the original gem; the full
244
- suite (182 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
277
+ suite (201 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
245
278
  before/after deltas that produced these tables.
246
279
 
247
280
  ## Installation
data/TODO.md CHANGED
@@ -17,31 +17,45 @@
17
17
  `#tokens` spec helper. Parse allocations −42% (603537 → 347037 / 500 games).
18
18
  - Speed up replay via a board-representation rewrite ("Approach B"): done.
19
19
  (b) ✓ (done in 1.3.0) Rewrote `Board` internals to the classic 0x88
20
- representation (128-cell array indexed by `rank*16+file`) and rewrote
21
- `MoveCalculator` to work entirely in single-integer square indices via
22
- `Board#at_index`/`#apply!`, so the replay hot path no longer allocates
23
- `[file,rank]` coordinate arrays or square-name strings. Off-board is a
24
- single bitmask (`(idx & 0x88).zero?`, ~1.6x faster than a 0..7 bounds
25
- check) and ray stepping is a single integer add. Algorithm unchanged, so
26
- output is byte-identical. Measured (immortal game): replay 798→535 µs/i
27
- (+49% throughput), allocations 1571→976 objects (−38%) / 92440→62064 bytes
28
- (−33%); parse+replay +21% throughput. 182 specs green, 0 new rubocop
29
- offenses vs main. The public string/coord API is preserved (additive).
20
+ representation (128-cell array indexed by `rank*16+file`) and rewrote
21
+ `MoveCalculator` to work entirely in single-integer square indices via
22
+ `Board#at_index`/`#apply!`, so the replay hot path no longer allocates
23
+ `[file,rank]` coordinate arrays or square-name strings. Off-board is a
24
+ single bitmask (`(idx & 0x88).zero?`, ~1.6x faster than a 0..7 bounds
25
+ check) and ray stepping is a single integer add. Algorithm unchanged, so
26
+ output is byte-identical. Measured (immortal game): replay 798→535 µs/i
27
+ (+49% throughput), allocations 1571→976 objects (−38%) / 92440→62064 bytes
28
+ (−33%); parse+replay +21% throughput. 182 specs green, 0 new rubocop
29
+ offenses vs main. The public string/coord API is preserved (additive).
30
+ (c) One related idea was left alone during cleanup rather than "fixed",
31
+ since fixing it would cost more than it's worth right now: `Board#squares`
32
+ rebuilds the full 8x8 array from `@cells` on every call (9 allocations,
33
+ 64 reads); it's off the replay hot path by design, but `FEN#to_s`
34
+ round-trips through it on every position-to-FEN call, so FEN generation
35
+ pays that cost repeatedly. Memoizing would mean invalidating the cache
36
+ from `update`/`apply!`, i.e. adding a write to the actual hot path to
37
+ speed up a path that isn't hot -- the wrong trade; if FEN generation
38
+ becomes hot, have it read `@cells` directly instead. Also considered
39
+ and not attempted: column-granularity copy-on-write in `dup` (the pre-0x88
40
+ Board only duplicated touched file-columns on write); the flat 0x88 array
41
+ trades that away for simplicity and the +49% throughput measured above,
42
+ and reintroducing it would need its own A/B before it's worth the
43
+ complexity.
30
44
  (a) ✗ (attempted, rejected) A piece-location index (piece → 0x88 indices)
31
- maintained in `update`/`apply!` and used for O(1) slider/leaper/king
32
- origin lookups. Implemented on top of (b), all 182 specs green, but it
33
- **regressed**: replay 526→727 µs/i (+38% slower), allocations 976→1591
34
- objects (+63%). Root cause: `Board#dup` (called every move) must clone
35
- the index (`transform_values(&:dup)` ≈ 12 piece arrays) — Board#dup went
36
- 91→676 objects — and every move pays per-update index maintenance
37
- (`<<`/`delete`) that pawns (the most common move type, whose origins are
38
- geometry-fixed and can't use the index) pay for no benefit. The index
39
- helps sliders/leapers (minority of moves) but the dup + maintenance cost
40
- is paid by every move. Conclusion: a global piece index is a loss for
41
- replay (where only ONE given move is validated, so ray-scanning from the
42
- destination is already cheap); it pays in move-*generation* libraries
43
- (chess.js/python-chess) that enumerate ALL legal moves. Not worth a COW
44
- variant either (maintenance + pawns). Reverted; (b) alone is the winner.
45
+ maintained in `update`/`apply!` and used for O(1) slider/leaper/king
46
+ origin lookups. Implemented on top of (b), all 182 specs green, but it
47
+ **regressed**: replay 526→727 µs/i (+38% slower), allocations 976→1591
48
+ objects (+63%). Root cause: `Board#dup` (called every move) must clone
49
+ the index (`transform_values(&:dup)` ≈ 12 piece arrays) — Board#dup went
50
+ 91→676 objects — and every move pays per-update index maintenance
51
+ (`<<`/`delete`) that pawns (the most common move type, whose origins are
52
+ geometry-fixed and can't use the index) pay for no benefit. The index
53
+ helps sliders/leapers (minority of moves) but the dup + maintenance cost
54
+ is paid by every move. Conclusion: a global piece index is a loss for
55
+ replay (where only ONE given move is validated, so ray-scanning from the
56
+ destination is already cheap); it pays in move-_generation_ libraries
57
+ (chess.js/python-chess) that enumerate ALL legal moves. Not worth a COW
58
+ variant either (maintenance + pawns). Reverted; (b) alone is the winner.
45
59
  - Replace the right-recursive `tag_section`/`variation_list` rules in
46
60
  `pgn_parser.y` with ordinary left-recursion plus one explicit `.reverse`
47
61
  at the point each list is consumed, so the legacy whittle-order
@@ -53,10 +67,3 @@
53
67
  to reuse as-is. The brace check is a bandaid for `clean_text` not fully
54
68
  normalizing multi-line/nested comments in one pass; fixing that at the
55
69
  source would let `moves=` reuse unconditionally.
56
- - `MoveCalculator#destination_coords` memoizes into `@dest_coords` based on
57
- `board`/`move`/`origin` never changing after `#initialize` — true today,
58
- but only by convention, since `board`, `move`, `origin` are all public
59
- `attr_accessor`s with no cache invalidation tied to their setters. If a
60
- future caller ever mutates and reuses a `MoveCalculator` instance, this
61
- memo goes stale silently. Either drop the public setters or invalidate
62
- `@dest_coords` when they're used.
@@ -1,25 +1,25 @@
1
1
  Corpus: 500 copies of the immortal game
2
2
 
3
3
  === 1. Parse-only allocations (500 games) ===
4
- total_allocated objects: 347037
5
- total_allocated bytes: 17977414
4
+ total_allocated objects: 288537
5
+ total_allocated bytes: 15640374
6
6
 
7
7
  === 2. Parse + replay allocations (500 games) ===
8
- total_allocated objects: 831530
9
- total_allocated bytes: 47966112
8
+ total_allocated objects: 773030
9
+ total_allocated bytes: 45626128
10
10
 
11
11
  === 3. Parse-only throughput (ips) ===
12
12
  ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
13
13
  Warming up --------------------------------------
14
14
  parse 500 games 1.000 i/100ms
15
15
  Calculating -------------------------------------
16
- parse 500 games 4.053 0.0%) i/s (246.70 ms/i) - 21.000 in 5.180770s
16
+ parse 500 games 4.92620.3%) i/s (203.01 ms/i) - 25.000 in 5.075163s
17
17
 
18
18
  === 4. Parse + replay throughput (ips) ===
19
19
  ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
20
20
  Warming up --------------------------------------
21
21
  parse+replay 500 games 1.000 i/100ms
22
22
  Calculating -------------------------------------
23
- parse+replay 500 games 1.872 (± 0.0%) i/s (534.26 ms/i) - 10.000 in 5.342615s
23
+ parse+replay 500 games 2.065 (± 0.0%) i/s (484.34 ms/i) - 11.000 in 5.327699s
24
24
 
25
25
  Done. Compare this file against bench/baseline_parse.txt after optimizations.
data/lib/pgn/board.rb CHANGED
@@ -27,10 +27,10 @@ module PGN
27
27
  ].freeze
28
28
 
29
29
  FILE_TO_INDEX = ('a'..'h').each_with_index.to_h
30
- INDEX_TO_FILE = FILE_TO_INDEX.map(&:reverse).to_h
30
+ INDEX_TO_FILE = FILE_TO_INDEX.invert
31
31
 
32
32
  RANK_TO_INDEX = ('1'..'8').each_with_index.to_h
33
- INDEX_TO_RANK = RANK_TO_INDEX.map(&:reverse).to_h
33
+ INDEX_TO_RANK = RANK_TO_INDEX.invert
34
34
 
35
35
  # algebraic to unicode piece lookup
36
36
  #
@@ -102,7 +102,6 @@ module PGN
102
102
  @cells[(r * 16) + f] = squares[f][r]
103
103
  end
104
104
  end
105
- @cells
106
105
  end
107
106
 
108
107
  # @overload at(str)
@@ -119,9 +118,9 @@ module PGN
119
118
  # board.at("e4") #=> "P"
120
119
  #
121
120
  def at(arg0, arg1 = nil)
122
- return @cells[(arg1 * 16) + arg0] unless arg1.nil?
121
+ return at_index(index_for(arg0, arg1)) unless arg1.nil?
123
122
 
124
- @cells[(rank_of(arg0) * 16) + file_of(arg0)]
123
+ at_index(index_of(arg0))
125
124
  end
126
125
 
127
126
  # @param changes [Hash<String, <String, nil>>] changes to make to the board
@@ -141,8 +140,7 @@ module PGN
141
140
  # board.update("e4", "P")
142
141
  #
143
142
  def update(square, piece)
144
- @cells[(rank_of(square) * 16) + file_of(square)] = piece
145
- self
143
+ update_index(index_of(square), piece)
146
144
  end
147
145
 
148
146
  # @param position [String] the square in algebraic notation
@@ -173,13 +171,25 @@ module PGN
173
171
  end.join("\n")
174
172
  end
175
173
 
174
+ # Build a {Board} directly from a 0x88 cell array, bypassing the 8x8
175
+ # -> 0x88 conversion in {#initialize}. Used by {#dup} (which runs every
176
+ # move) to skip the per-square rebuild; the cell array is already in the
177
+ # canonical 128-cell layout.
178
+ #
179
+ # @param cells [Array<String, nil>] a 128-cell 0x88 array
180
+ # @return [PGN::Board]
181
+ #
182
+ def self.from_cells(cells)
183
+ board = allocate
184
+ board.instance_variable_set(:@cells, cells)
185
+ board
186
+ end
187
+
176
188
  # @return [PGN::Board] a copy of self. Copies the 128-cell 0x88 array;
177
189
  # mutations to the copy do not affect the original.
178
190
  #
179
191
  def dup
180
- copy = PGN::Board.allocate
181
- copy.instance_variable_set(:@cells, @cells.dup)
182
- copy
192
+ self.class.from_cells(@cells.dup)
183
193
  end
184
194
 
185
195
  # -- 0x88 hot-path API (integer indices) ---------------------------------
@@ -235,6 +245,25 @@ module PGN
235
245
  self
236
246
  end
237
247
 
248
+ # Whether a 0x88 index is on the board (see the class doc for the
249
+ # bitmask this tests).
250
+ #
251
+ # @param idx [Integer] a 0x88 square index
252
+ # @return [Boolean]
253
+ #
254
+ def on_board?(idx)
255
+ (idx & 0x88).zero? # rubocop:disable Style/BitwisePredicate
256
+ end
257
+
258
+ # The algebraic square name of a 0x88 index.
259
+ #
260
+ # @param idx [Integer] a 0x88 square index
261
+ # @return [String] e.g. "e4"
262
+ #
263
+ def square_name(idx)
264
+ INDEX_TO_FILE[idx & 0x0F] + INDEX_TO_RANK[idx >> 4]
265
+ end
266
+
238
267
  private
239
268
 
240
269
  def file_of(square)
data/lib/pgn/lexer.rb CHANGED
@@ -117,6 +117,39 @@ module PGN
117
117
  [:tag_name, TAG_NAME, false]
118
118
  ].freeze.each(&:freeze)
119
119
 
120
+ # Byte-dispatch table: maps the leading byte of the next token to the
121
+ # ordered list of RULES indices that could possibly match it. This lets
122
+ # {#scan_one} try one or two regexes for the common tokens instead of
123
+ # walking all nine rules in order (StringScanner#scan was ~23% of parse
124
+ # CPU and the rule loop ~38% inclusive per profiling). The order within
125
+ # each list mirrors RULES, so tokenization is byte-compatible with the
126
+ # linear scan. Indices: 0 wsp, 1 pgn_comment, 2 game_termination,
127
+ # 3 san_move, 4 move_number, 5 nag, 6 comment, 7 string, 8 tag_name.
128
+ BYTE_DISPATCH = begin
129
+ h = {
130
+ 9 => [0], 10 => [0], 11 => [0], 12 => [0], 13 => [0], 32 => [0], # whitespace
131
+ 37 => [1], # % pgn_comment
132
+ 42 => [2], # * game_termination
133
+ 34 => [7], # " string
134
+ 123 => [6], # { comment
135
+ 36 => [5], 63 => [5], 33 => [5], # $ ? ! nag
136
+ 48 => [2, 3, 4, 8], 49 => [2, 4, 8], # 0, 1 (term/castle/num/tag)
137
+ 95 => [8] # _ tag_name
138
+ }
139
+ (50..57).each { |b| h[b] = [4, 8] } # 2..9 move_number/tag_name
140
+ [66, 75, 78, 79, 81, 82].each { |b| h[b] = [3, 8] } # B K N O Q R san_move/tag
141
+ (97..104).each { |b| h[b] = [3, 8] } # a-h pawn san_move/tag
142
+ # other tag_name letters
143
+ ((65..90).to_a + (105..122).to_a - [66, 75, 78, 79, 81, 82]).each do |b|
144
+ h[b] = [8]
145
+ end
146
+ h.each_value(&:freeze)
147
+ h.freeze
148
+ end
149
+
150
+ # Fallback for bytes not in BYTE_DISPATCH: try every rule in order.
151
+ ALL_RULES = (0...RULES.length).to_a.freeze
152
+
120
153
  # Single-character literals, matched by their byte value: [type, frozen value].
121
154
  LITERAL_BYTES = {
122
155
  91 => [:lbracket, '['], # [
@@ -128,7 +161,6 @@ module PGN
128
161
  def initialize(input)
129
162
  @input = input
130
163
  @ss = StringScanner.new(input)
131
- @line = 1
132
164
  @game_starts = []
133
165
  @between_games = true # at start we are "between" games
134
166
  end
@@ -149,7 +181,8 @@ module PGN
149
181
  type, value = next_token_pair
150
182
  return nil unless type
151
183
 
152
- Token.new(type: type, value: value, offset: @last_offset, line: @line)
184
+ Token.new(type: type, value: value, offset: @last_offset,
185
+ line: line_at(@last_offset))
153
186
  end
154
187
 
155
188
  # Fast path for the parser: returns [type, value] for the next
@@ -170,7 +203,6 @@ module PGN
170
203
  end
171
204
 
172
205
  value = scan_one
173
- advance_line(value)
174
206
  next if @scan_discarded
175
207
 
176
208
  note_token(@scan_type, off)
@@ -185,17 +217,20 @@ module PGN
185
217
  # Try each terminal rule in order; return the matched string for the
186
218
  # first match (stashing its type and discarded flag in +@scan_type+ /
187
219
  # +@scan_discarded+ so the caller avoids allocating a 3-element tuple),
188
- # or raise if nothing matches at the current position.
220
+ # or raise if nothing matches at the current position. Uses
221
+ # {BYTE_DISPATCH} to try only the rules that can match the leading byte.
189
222
  def scan_one
190
- RULES.each do |(type, re, discarded)|
191
- if (m = @ss.scan(re))
192
- @scan_type = type
193
- @scan_discarded = discarded
194
- return m
195
- end
223
+ indices = BYTE_DISPATCH[@input.getbyte(@ss.pos)] || ALL_RULES
224
+ indices.each do |i|
225
+ type, re, discarded = RULES[i]
226
+ next unless (m = @ss.scan(re))
227
+
228
+ @scan_type = type
229
+ @scan_discarded = discarded
230
+ return m
196
231
  end
197
232
  raise UnconsumedInputError,
198
- "Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{@line}"
233
+ "Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{line_at(@ss.pos)}"
199
234
  end
200
235
 
201
236
  # Track per-game content-start offsets for verbatim pgn slicing.
@@ -210,8 +245,12 @@ module PGN
210
245
  end
211
246
  end
212
247
 
213
- def advance_line(str)
214
- @line += str.count("\n")
248
+ # Line number at a byte offset, computed lazily only for error messages
249
+ # and the spec `tokens` helper. Keeping a running `@line` on the parse hot
250
+ # path (one `str.count("\n")` per token) was ~6% of parse CPU for a value
251
+ # the parser never reads.
252
+ def line_at(off)
253
+ 1 + @input.byteslice(0, off).count("\n")
215
254
  end
216
255
  end
217
256
 
@@ -30,7 +30,7 @@ module PGN
30
30
  # 0x88 single-step offsets for knight and king.
31
31
  #
32
32
  STEP = {
33
- 'k' => [-1, 1, -16, 16, -15, 15, -17, 17],
33
+ 'k' => SLIDE['q'],
34
34
  'n' => [33, 31, -31, -33, 18, 14, -14, -18]
35
35
  }.freeze
36
36
 
@@ -42,15 +42,6 @@ module PGN
42
42
  'p' => { capture: [15, 17], normal: [16], double: [32] }
43
43
  }.freeze
44
44
 
45
- # The squares to update for each castling move, keyed by 0x88 index.
46
- #
47
- CASTLING = {
48
- 'Q' => { 0 => nil, 2 => 'K', 3 => 'R', 4 => nil },
49
- 'K' => { 4 => nil, 5 => 'R', 6 => 'K', 7 => nil },
50
- 'q' => { 112 => nil, 114 => 'k', 115 => 'r', 116 => nil },
51
- 'k' => { 116 => nil, 117 => 'r', 118 => 'k', 119 => nil }
52
- }.freeze
53
-
54
45
  # Corner-square 0x88 indices, used for castling-restriction bookkeeping
55
46
  # (a rook leaving or being captured on a corner drops the matching right).
56
47
  #
@@ -59,6 +50,29 @@ module PGN
59
50
  A8 = 112
60
51
  H8 = 119
61
52
 
53
+ # King/rook landing squares for castling, named for readability in
54
+ # {CASTLING} below.
55
+ #
56
+ C1 = 2
57
+ D1 = 3
58
+ E1 = 4
59
+ F1 = 5
60
+ G1 = 6
61
+ C8 = 114
62
+ D8 = 115
63
+ E8 = 116
64
+ F8 = 117
65
+ G8 = 118
66
+
67
+ # The squares to update for each castling move, keyed by 0x88 index.
68
+ #
69
+ CASTLING = {
70
+ 'Q' => { A1 => nil, C1 => 'K', D1 => 'R', E1 => nil },
71
+ 'K' => { E1 => nil, F1 => 'R', G1 => 'K', H1 => nil },
72
+ 'q' => { A8 => nil, C8 => 'k', D8 => 'r', E8 => nil },
73
+ 'k' => { E8 => nil, F8 => 'r', G8 => 'k', H8 => nil }
74
+ }.freeze
75
+
62
76
  # rook-origin (0x88 index) -> castling restriction it drops.
63
77
  #
64
78
  ROOK_RESTRICTIONS = { A1 => 'Q', H1 => 'K', A8 => 'q', H8 => 'k' }.freeze
@@ -69,14 +83,14 @@ module PGN
69
83
  WHITE_CASTLE = %w[K Q].freeze
70
84
  BLACK_CASTLE = %w[k q].freeze
71
85
 
72
- attr_accessor :board, :move
86
+ attr_reader :board, :move
73
87
 
74
88
  # @param board [PGN::Board] the current board
75
89
  # @param move [PGN::Move] the current move
76
90
  #
77
91
  def initialize(board, move)
78
- self.board = board
79
- self.move = move
92
+ @board = board
93
+ @move = move
80
94
  @origin_idx = compute_origin
81
95
  end
82
96
 
@@ -87,7 +101,7 @@ module PGN
87
101
  def origin
88
102
  return nil if @origin_idx.nil?
89
103
 
90
- board.position_for([@origin_idx & 0x0F, @origin_idx >> 4])
104
+ board.square_name(@origin_idx)
91
105
  end
92
106
 
93
107
  # @return [PGN::Board] the board after the move is made
@@ -215,7 +229,7 @@ module PGN
215
229
  possibilities = []
216
230
  offsets.each do |off|
217
231
  target = dest + off
218
- next unless (target & 0x88).zero? # rubocop:disable Style/BitwisePredicate
232
+ next unless board.on_board?(target)
219
233
 
220
234
  possibilities << target if board.at_index(target) == move.piece
221
235
  end
@@ -250,7 +264,7 @@ module PGN
250
264
  return possibilities unless move.disambiguation
251
265
 
252
266
  possibilities.select do |idx|
253
- board.position_for([idx & 0x0F, idx >> 4]).match(move.disambiguation)
267
+ board.square_name(idx).match(move.disambiguation)
254
268
  end
255
269
  end
256
270
 
@@ -290,7 +304,7 @@ module PGN
290
304
  #
291
305
  def first_piece(idx, off)
292
306
  idx += off
293
- while (idx & 0x88).zero? # rubocop:disable Style/BitwisePredicate
307
+ while board.on_board?(idx)
294
308
  square = board.at_index(idx)
295
309
  return idx if square
296
310
 
@@ -315,7 +329,7 @@ module PGN
315
329
  return nil if move.castle
316
330
  return nil unless move.capture && board.at_index(dest_idx).nil?
317
331
 
318
- (origin_rank * 16) + (dest_idx & 0x0F)
332
+ board.index_for(dest_idx & 0x0F, origin_rank)
319
333
  end
320
334
 
321
335
  def king_position
@@ -323,7 +337,7 @@ module PGN
323
337
 
324
338
  0.upto(7) do |rank|
325
339
  0.upto(7) do |file|
326
- idx = (rank * 16) + file
340
+ idx = board.index_for(file, rank)
327
341
  return idx if board.at_index(idx) == king
328
342
  end
329
343
  end
@@ -0,0 +1,474 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGN
4
+ # {PGN::Notation} generates Standard Algebraic Notation (SAN) for a single
5
+ # move described in coordinate form -- an origin square, a destination
6
+ # square, and an optional promotion piece -- given the {PGN::Position}
7
+ # *before* the move is played.
8
+ #
9
+ # Unlike {PGN::Move}, which *parses* an existing SAN string, {PGN::Notation}
10
+ # *builds* SAN from coordinates. This requires the kind of legality analysis
11
+ # the rest of the gem does not perform: attack detection, "does this move
12
+ # leave the mover's king in check", disambiguation among same-type pieces
13
+ # that can legally reach the destination, and check (+) / checkmate (#)
14
+ # suffix detection (the latter needing full legal-move generation for the
15
+ # side to move).
16
+ #
17
+ # The board is addressed with the same 0x88 integer indices the rest of the
18
+ # gem uses (see {PGN::Board}); an index is on-board when
19
+ # `(idx & 0x88).zero?`.
20
+ #
21
+ # @example
22
+ # PGN::Notation.san(PGN::Position.start, 'g1', 'f3') #=> "Nf3"
23
+ # PGN::Notation.san_from_fen(PGN::FEN::INITIAL, 'e2', 'e4') #=> "e4"
24
+ #
25
+ class Notation
26
+ # 0x88 single-step offsets for a knight (symmetric, so they double as
27
+ # attack deltas).
28
+ KNIGHT_OFFS = [33, 31, -31, -33, 18, 14, -14, -18].freeze
29
+ # 0x88 single-step offsets for a king (symmetric).
30
+ KING_OFFS = [-1, 1, -16, 16, -15, 15, -17, 17].freeze
31
+ BISHOP_DIRS = [-15, 15, -17, 17].freeze
32
+ ROOK_DIRS = [-1, 1, -16, 16].freeze
33
+
34
+ # Build SAN for a coordinate move from a {PGN::Position}.
35
+ #
36
+ # @param position [PGN::Position] the position *before* the move
37
+ # @param from [String] origin square in algebraic notation ("e2")
38
+ # @param to [String] destination square in algebraic notation ("e4")
39
+ # @param promotion [String, nil] promotion piece letter ("q"/"Q"/"n"...);
40
+ # case-insensitive; SAN always emits an uppercase letter
41
+ # @return [String] the move in SAN, e.g. "Nf3", "exd5", "O-O", "Ra8#"
42
+ #
43
+ def self.san(position, from, to, promotion = nil)
44
+ new(position).san(from, to, promotion)
45
+ end
46
+
47
+ # Convenience: build SAN directly from a FEN string.
48
+ #
49
+ # @param fen [String] the FEN of the position *before* the move
50
+ # @param (see .san)
51
+ # @return [String] the move in SAN
52
+ #
53
+ def self.san_from_fen(fen, from, to, promotion = nil)
54
+ san(PGN::FEN.new(fen).to_position, from, to, promotion)
55
+ end
56
+
57
+ def initialize(position)
58
+ @position = position
59
+ @board = position.board
60
+ @player = position.player
61
+ @mover = (@player == :white ? 'w' : 'b')
62
+ @enemy = (@player == :white ? 'b' : 'w')
63
+ end
64
+
65
+ # @param (see .san)
66
+ # @return [String]
67
+ def san(from, to, promotion = nil)
68
+ from_idx = @board.index_of(from)
69
+ to_idx = @board.index_of(to)
70
+ piece = @board.at_index(from_idx)
71
+ raise ArgumentError, "no piece on #{from}" if piece.nil?
72
+
73
+ promotion = promotion.upcase if promotion
74
+
75
+ castling = piece.upcase == 'K' && ((from_idx & 0x0F) - (to_idx & 0x0F)).abs == 2
76
+ capture = castling ? false : capture?(piece, from_idx, to_idx)
77
+
78
+ body =
79
+ if castling
80
+ to_idx < from_idx ? 'O-O-O' : 'O-O'
81
+ elsif piece.upcase == 'P'
82
+ pawn_body(from_idx, to_idx, capture, promotion)
83
+ else
84
+ piece_body(piece, from_idx, to_idx, capture)
85
+ end
86
+
87
+ body + suffix(from_idx, to_idx, piece, promotion, castling)
88
+ end
89
+
90
+ private
91
+
92
+ # -- body construction -------------------------------------------------
93
+
94
+ def pawn_body(from_idx, to_idx, capture, promotion)
95
+ dest = @board.position_for([to_idx & 0x0F, to_idx >> 4])
96
+ promo = promotion ? "=#{promotion}" : ''
97
+ if capture
98
+ "#{Board::INDEX_TO_FILE[from_idx & 0x0F]}x#{dest}#{promo}"
99
+ else
100
+ "#{dest}#{promo}"
101
+ end
102
+ end
103
+
104
+ def piece_body(piece, from_idx, to_idx, capture)
105
+ disamb = disambiguation(piece, from_idx, to_idx)
106
+ dest = @board.position_for([to_idx & 0x0F, to_idx >> 4])
107
+ parts = [piece.upcase, disamb]
108
+ parts << 'x' if capture
109
+ parts << dest
110
+ parts.join
111
+ end
112
+
113
+ # Disambiguate a non-pawn, non-castling move among same-type pieces that
114
+ # can *legally* reach the destination. Returns '' when none can.
115
+ def disambiguation(piece, from_idx, to_idx)
116
+ others = same_type_legals(piece, from_idx, to_idx)
117
+ return '' if others.empty?
118
+
119
+ from_file = from_idx & 0x0F
120
+ from_rank = from_idx >> 4
121
+ file_clash = others.any? { |o| (o & 0x0F) == from_file }
122
+ rank_clash = others.any? { |o| (o >> 4) == from_rank }
123
+
124
+ if !file_clash
125
+ Board::INDEX_TO_FILE[from_file]
126
+ elsif !rank_clash
127
+ Board::INDEX_TO_RANK[from_rank]
128
+ else
129
+ Board::INDEX_TO_FILE[from_file] + Board::INDEX_TO_RANK[from_rank]
130
+ end
131
+ end
132
+
133
+ # Other squares holding `piece` whose move to `to_idx` is legal.
134
+ def same_type_legals(piece, from_idx, to_idx)
135
+ (0...128).each_with_object([]) do |idx, acc|
136
+ next if (idx & 0x88) != 0
137
+ next if idx == from_idx
138
+ next unless @board.at_index(idx) == piece
139
+
140
+ acc << idx if reaches?(piece, idx, to_idx) && legal_after?(idx, to_idx, piece)
141
+ end
142
+ end
143
+
144
+ # -- check / checkmate suffix -----------------------------------------
145
+
146
+ def suffix(from_idx, to_idx, piece, promotion, castling)
147
+ nb = apply_move(from_idx, to_idx, piece, promotion, castling)
148
+ opp_king = king_idx(nb, @enemy)
149
+ return '' unless opp_king && attacked?(nb, opp_king, @mover)
150
+
151
+ result_pos = PGN::Position.new(
152
+ nb,
153
+ @player == :white ? :black : :white,
154
+ [],
155
+ new_ep_square(piece, from_idx, to_idx),
156
+ 0,
157
+ @position.fullmove
158
+ )
159
+ any_legal_move?(result_pos) ? '+' : '#'
160
+ end
161
+
162
+ # -- move application -------------------------------------------------
163
+
164
+ def apply_move(from_idx, to_idx, piece, promotion, castling)
165
+ nb = @board.dup
166
+ return castle!(nb, from_idx, to_idx, piece) if castling
167
+
168
+ ep = ep_capture_square(piece, from_idx, to_idx)
169
+ nb.update_index(ep, nil) if ep
170
+ nb.update_index(from_idx, nil)
171
+ nb.update_index(to_idx, promotion ? promoted(promotion) : piece)
172
+ nb
173
+ end
174
+
175
+ def castle!(board, from_idx, to_idx, piece)
176
+ rank = from_idx >> 4
177
+ rook = piece.upcase == 'K' ? 'R' : 'r'
178
+ board.update_index(from_idx, nil)
179
+ board.update_index(to_idx, piece)
180
+ if to_idx < from_idx # queenside
181
+ board.update_index(rank * 16, nil)
182
+ board.update_index((rank * 16) + 3, rook)
183
+ else # kingside
184
+ board.update_index((rank * 16) + 7, nil)
185
+ board.update_index((rank * 16) + 5, rook)
186
+ end
187
+ board
188
+ end
189
+
190
+ def promoted(promotion)
191
+ @player == :white ? promotion.upcase : promotion.downcase
192
+ end
193
+
194
+ # En-passant captured-pawn square (or nil): a pawn moving diagonally to
195
+ # an empty destination must be capturing en passant.
196
+ def ep_capture_square(piece, from_idx, to_idx)
197
+ return nil unless piece.upcase == 'P'
198
+ return nil if (from_idx & 0x0F) == (to_idx & 0x0F)
199
+
200
+ @board.at_index(to_idx).nil? ? ((from_idx >> 4) * 16) + (to_idx & 0x0F) : nil
201
+ end
202
+
203
+ # The en-passant target square left behind by a double pawn push, for the
204
+ # resulting position (so an escaping ep capture is considered for mate).
205
+ def new_ep_square(piece, from_idx, to_idx)
206
+ return nil unless piece.upcase == 'P'
207
+ return nil if ((from_idx >> 4) - (to_idx >> 4)).abs != 2
208
+
209
+ mid = ((from_idx >> 4) + (to_idx >> 4)) / 2
210
+ Board::INDEX_TO_FILE[from_idx & 0x0F] + Board::INDEX_TO_RANK[mid]
211
+ end
212
+
213
+ # -- legality ---------------------------------------------------------
214
+
215
+ # Is `piece` from `from_idx` to `to_idx` legal (own king safe after)?
216
+ def legal_after?(from_idx, to_idx, piece)
217
+ nb = @board.dup
218
+ ep = ep_capture_square(piece, from_idx, to_idx)
219
+ nb.update_index(ep, nil) if ep
220
+ nb.update_index(from_idx, nil)
221
+ nb.update_index(to_idx, piece)
222
+ king = king_idx(nb, @mover)
223
+ king && !attacked?(nb, king, @enemy)
224
+ end
225
+
226
+ # Does `piece` at `from_idx` pseudo-legally reach `to_idx` on the board?
227
+ # (Sliders honor blockers; the destination may be empty or enemy-occupied.)
228
+ def reaches?(piece, from_idx, to_idx)
229
+ return false if from_idx == to_idx
230
+
231
+ case piece.upcase
232
+ when 'N'
233
+ KNIGHT_OFFS.any? { |o| (from_idx + o) == to_idx }
234
+ when 'K'
235
+ KING_OFFS.any? { |o| (from_idx + o) == to_idx }
236
+ when 'B', 'R', 'Q'
237
+ slider_reaches?(piece.upcase, from_idx, to_idx)
238
+ else
239
+ false
240
+ end
241
+ end
242
+
243
+ def slider_reaches?(piece_up, from_idx, to_idx)
244
+ slider_dirs(piece_up).any? do |off|
245
+ i = from_idx + off
246
+ while (i & 0x88).zero?
247
+ return true if i == to_idx
248
+ break if @board.at_index(i)
249
+
250
+ i += off
251
+ end
252
+ false
253
+ end
254
+ end
255
+
256
+ # Capture detection for the *played* move (used in the SAN body).
257
+ def capture?(piece, from_idx, to_idx)
258
+ if piece.upcase == 'P'
259
+ (from_idx & 0x0F) != (to_idx & 0x0F) # any diagonal pawn move captures
260
+ else
261
+ target = @board.at_index(to_idx)
262
+ target && !own?(target, @mover)
263
+ end
264
+ end
265
+
266
+ def own?(piece, color)
267
+ color == 'w' ? piece == piece.upcase : piece == piece.downcase
268
+ end
269
+
270
+ # Slider ray directions for a piece letter ('B'/'R'/'Q').
271
+ def slider_dirs(piece_up)
272
+ case piece_up
273
+ when 'B' then BISHOP_DIRS
274
+ when 'R' then ROOK_DIRS
275
+ else BISHOP_DIRS + ROOK_DIRS
276
+ end
277
+ end
278
+
279
+ # -- attack / legality helpers ----------------------------------------
280
+
281
+ def king_idx(board, color)
282
+ king = color == 'w' ? 'K' : 'k'
283
+ (0...128).each do |idx|
284
+ next if (idx & 0x88) != 0
285
+
286
+ return idx if board.at_index(idx) == king
287
+ end
288
+ nil
289
+ end
290
+
291
+ # Is `target` attacked by `color`-colored pieces on `board`?
292
+ def attacked?(board, target, color)
293
+ pawn_attacked?(board, target, color) ||
294
+ knight_attacked?(board, target, color) ||
295
+ king_attacked?(board, target, color) ||
296
+ slider_attacked?(board, target, color)
297
+ end
298
+
299
+ def pawn_attacked?(board, target, color)
300
+ offs = color == 'w' ? [-15, -17] : [15, 17]
301
+ pawn = color == 'w' ? 'P' : 'p'
302
+ offs.any? do |off|
303
+ i = target + off
304
+ (i & 0x88).zero? && board.at_index(i) == pawn
305
+ end
306
+ end
307
+
308
+ def knight_attacked?(board, target, color)
309
+ knight = color == 'w' ? 'N' : 'n'
310
+ KNIGHT_OFFS.any? do |off|
311
+ i = target + off
312
+ (i & 0x88).zero? && board.at_index(i) == knight
313
+ end
314
+ end
315
+
316
+ def king_attacked?(board, target, color)
317
+ king = color == 'w' ? 'K' : 'k'
318
+ KING_OFFS.any? do |off|
319
+ i = target + off
320
+ (i & 0x88).zero? && board.at_index(i) == king
321
+ end
322
+ end
323
+
324
+ def slider_attacked?(board, target, color)
325
+ bishop = color == 'w' ? 'B' : 'b'
326
+ rook = color == 'w' ? 'R' : 'r'
327
+ queen = color == 'w' ? 'Q' : 'q'
328
+ ray_hit?(board, target, BISHOP_DIRS) { |p| p == bishop || p == queen } ||
329
+ ray_hit?(board, target, ROOK_DIRS) { |p| p == rook || p == queen }
330
+ end
331
+
332
+ def ray_hit?(board, target, dirs)
333
+ dirs.each do |off|
334
+ i = target + off
335
+ while (i & 0x88).zero?
336
+ piece = board.at_index(i)
337
+ if piece
338
+ return true if yield(piece)
339
+ break
340
+ end
341
+ i += off
342
+ end
343
+ end
344
+ false
345
+ end
346
+
347
+ # -- legal-move generation (for checkmate detection) ------------------
348
+
349
+ def any_legal_move?(position)
350
+ board = position.board
351
+ color = position.player == :white ? 'w' : 'b'
352
+ ep_idx = ep_target_idx(position, board)
353
+
354
+ (0...128).any? do |idx|
355
+ next if (idx & 0x88) != 0
356
+
357
+ piece = board.at_index(idx)
358
+ piece && own?(piece, color) && move_from?(board, idx, piece, color, ep_idx)
359
+ end
360
+ end
361
+
362
+ def ep_target_idx(position, board)
363
+ ep = position.en_passant
364
+ return nil if ep.nil? || ep == '-' || ep.empty?
365
+
366
+ board.index_of(ep)
367
+ end
368
+
369
+ # Yields true if `piece` at `idx` has any legal move (escapes check).
370
+ def move_from?(board, idx, piece, color, ep_idx)
371
+ up = piece.upcase
372
+ case up
373
+ when 'N' then leaper_moves?(board, idx, piece, color, KNIGHT_OFFS)
374
+ when 'K' then leaper_moves?(board, idx, piece, color, KING_OFFS)
375
+ when 'B', 'R', 'Q'
376
+ slider_moves?(board, idx, piece, color, slider_dirs(up))
377
+ when 'P'
378
+ pawn_moves?(board, idx, piece, color, ep_idx)
379
+ else
380
+ false
381
+ end
382
+ end
383
+
384
+ def leaper_moves?(board, idx, piece, color, offs)
385
+ offs.any? do |off|
386
+ t = idx + off
387
+ (t & 0x88).zero? && landable?(board, t, color) && safe?(board, idx, t, piece, nil)
388
+ end
389
+ end
390
+
391
+ def slider_moves?(board, idx, piece, color, dirs)
392
+ dirs.any? do |off|
393
+ t = idx + off
394
+ while (t & 0x88).zero?
395
+ tp = board.at_index(t)
396
+ if tp.nil?
397
+ return true if safe?(board, idx, t, piece, nil)
398
+ else
399
+ return true if !own?(tp, color) && safe?(board, idx, t, piece, nil)
400
+ break
401
+ end
402
+ t += off
403
+ end
404
+ false
405
+ end
406
+ end
407
+
408
+ def pawn_moves?(board, idx, piece, color, ep_idx)
409
+ pawn_pushes?(board, idx, piece, color) ||
410
+ pawn_captures?(board, idx, piece, color, ep_idx)
411
+ end
412
+
413
+ def pawn_pushes?(board, idx, piece, color)
414
+ dir = color == 'w' ? 16 : -16
415
+ start_rank = color == 'w' ? 1 : 6
416
+ promo_rank = color == 'w' ? 7 : 0
417
+
418
+ t = idx + dir
419
+ return false unless (t & 0x88).zero? && board.at_index(t).nil?
420
+
421
+ promo = (t >> 4 == promo_rank) ? 'Q' : nil
422
+ return true if safe?(board, idx, t, piece, promo)
423
+
424
+ t2 = idx + (dir * 2)
425
+ (idx >> 4) == start_rank && board.at_index(t2).nil? && safe?(board, idx, t2, piece, nil)
426
+ end
427
+
428
+ def pawn_captures?(board, idx, piece, color, ep_idx) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
429
+ promo_rank = color == 'w' ? 7 : 0
430
+ caps = color == 'w' ? [15, 17] : [-15, -17]
431
+ caps.any? do |off|
432
+ t = idx + off
433
+ next false unless (t & 0x88).zero?
434
+
435
+ tp = board.at_index(t)
436
+ promo = (t >> 4 == promo_rank) ? 'Q' : nil
437
+ if tp && !own?(tp, color)
438
+ safe?(board, idx, t, piece, promo)
439
+ elsif tp.nil? && t == ep_idx
440
+ safe?(board, idx, t, piece, nil)
441
+ else
442
+ false
443
+ end
444
+ end
445
+ end
446
+
447
+ def landable?(board, target, color)
448
+ tp = board.at_index(target)
449
+ tp.nil? || !own?(tp, color)
450
+ end
451
+
452
+ # Apply a pseudo-move (with ep + promotion) on a copy and test own-king
453
+ # safety. The moving side is derived from `piece`'s case.
454
+ def safe?(board, from_idx, to_idx, piece, promotion)
455
+ nb = board.dup
456
+ if piece.upcase == 'P' && (from_idx & 0x0F) != (to_idx & 0x0F) && board.at_index(to_idx).nil?
457
+ nb.update_index(((from_idx >> 4) * 16) + (to_idx & 0x0F), nil) # ep capture
458
+ end
459
+ nb.update_index(from_idx, nil)
460
+ nb.update_index(to_idx, place_piece(piece, promotion))
461
+ color = own?(piece, 'w') ? 'w' : 'b'
462
+ king = king_idx(nb, color)
463
+ king && !attacked?(nb, king, color == 'w' ? 'b' : 'w')
464
+ end
465
+
466
+ # The piece letter to place on the destination after a pseudo-move.
467
+ def place_piece(piece, promotion)
468
+ return piece unless promotion
469
+
470
+ white = piece == piece.upcase
471
+ white ? promotion.upcase : promotion.downcase
472
+ end
473
+ end
474
+ end
@@ -21,8 +21,10 @@ module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 97)
21
21
  def next_token
22
22
  pair = @lexer.next_token_pair
23
23
  return [false, false] unless pair
24
- type, value = pair
25
- [translate_type(type), value]
24
+ # Mutate the lexer's [type, value] pair in place rather than allocating
25
+ # a second translated pair -- one array per token is the Racc floor.
26
+ pair[0] = translate_type(pair[0])
27
+ pair
26
28
  end
27
29
 
28
30
  private
data/lib/pgn/pgn_parser.y CHANGED
@@ -106,8 +106,10 @@ rule
106
106
  def next_token
107
107
  pair = @lexer.next_token_pair
108
108
  return [false, false] unless pair
109
- type, value = pair
110
- [translate_type(type), value]
109
+ # Mutate the lexer's [type, value] pair in place rather than allocating
110
+ # a second translated pair -- one array per token is the Racc floor.
111
+ pair[0] = translate_type(pair[0])
112
+ pair
111
113
  end
112
114
 
113
115
  private
data/lib/pgn/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module PGN
2
- VERSION = '1.3.0'.freeze
2
+ VERSION = '1.5.0'.freeze
3
3
  end
data/lib/pgn.rb CHANGED
@@ -4,6 +4,7 @@ require 'pgn/game'
4
4
  require 'pgn/move'
5
5
  require 'pgn/move_calculator'
6
6
  require 'pgn/lexer'
7
+ require 'pgn/notation'
7
8
  require 'pgn/pgn_parser'
8
9
  require 'pgn/parser'
9
10
  require 'pgn/position'
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Helper: build a Position from a FEN string.
6
+ def pos(fen)
7
+ PGN::FEN.new(fen).to_position
8
+ end
9
+
10
+ describe PGN::Notation do
11
+ # ------------------------------------------------------------------
12
+ # Public API
13
+ # ------------------------------------------------------------------
14
+ describe '.san' do
15
+ it 'generates a plain pawn push from coordinates' do
16
+ expect(PGN::Notation.san(pos('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
17
+ 'e2', 'e4')).to eq('e4')
18
+ end
19
+
20
+ it 'generates a knight move with the piece letter' do
21
+ expect(PGN::Notation.san(pos('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
22
+ 'g1', 'f3')).to eq('Nf3')
23
+ end
24
+
25
+ it 'generates a pawn capture with the origin file' do
26
+ fen = 'rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 2'
27
+ expect(PGN::Notation.san(pos(fen), 'e4', 'd5')).to eq('exd5')
28
+ end
29
+
30
+ it 'generates an en passant capture' do
31
+ fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
32
+ expect(PGN::Notation.san(pos(fen), 'e5', 'd6')).to eq('exd6')
33
+ end
34
+
35
+ it 'generates a pawn promotion' do
36
+ fen = '8/P7/6k1/8/8/8/8/4K3 w - - 0 1'
37
+ expect(PGN::Notation.san(pos(fen), 'a7', 'a8', 'q')).to eq('a8=Q')
38
+ end
39
+
40
+ it 'generates a capture promotion' do
41
+ fen = '1n6/P7/6k1/8/8/8/8/4K3 w - - 0 1'
42
+ expect(PGN::Notation.san(pos(fen), 'a7', 'b8', 'q')).to eq('axb8=Q')
43
+ end
44
+
45
+ it 'generates kingside castling' do
46
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
47
+ expect(PGN::Notation.san(pos(fen), 'e1', 'g1')).to eq('O-O')
48
+ end
49
+
50
+ it 'generates queenside castling' do
51
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
52
+ expect(PGN::Notation.san(pos(fen), 'e1', 'c1')).to eq('O-O-O')
53
+ end
54
+
55
+ it 'generates kingside castling for black' do
56
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1'
57
+ expect(PGN::Notation.san(pos(fen), 'e8', 'g8')).to eq('O-O')
58
+ end
59
+ end
60
+
61
+ describe '.san disambiguation' do
62
+ it 'disambiguates knights by file' do
63
+ # Knights on c3 and d4 both reach b5.
64
+ fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
65
+ expect(PGN::Notation.san(pos(fen), 'd4', 'b5')).to eq('Ndb5')
66
+ expect(PGN::Notation.san(pos(fen), 'c3', 'b5')).to eq('Ncb5')
67
+ end
68
+
69
+ it 'disambiguates rooks by rank when on the same file' do
70
+ # White rooks on a1 and a8 both reach a4 (file a clear).
71
+ fen = 'R7/8/8/4k3/8/8/8/R3K3 w - - 0 1'
72
+ expect(PGN::Notation.san(pos(fen), 'a1', 'a4')).to eq('R1a4')
73
+ expect(PGN::Notation.san(pos(fen), 'a8', 'a4')).to eq('R8a4')
74
+ end
75
+
76
+ it 'disambiguates queens by full square when file and rank both clash' do
77
+ # Queens on c1, f1, f4 all reach c4. The f1 queen shares file f with
78
+ # f4 and rank 1 with c1, so only the full square disambiguates.
79
+ fen = '7k/8/8/8/5Q2/8/8/2Q2Q1K w - - 0 1'
80
+ expect(PGN::Notation.san(pos(fen), 'f1', 'c4')).to eq('Qf1c4')
81
+ end
82
+
83
+ it 'omits disambiguation when only one piece can legally move there' do
84
+ # Two knights both pseudo-reach d2, but the e4 knight is pinned
85
+ # on the e-file (moving it exposes the king on e1 to the rook on e8),
86
+ # so only the b1 knight can legally play Nd2 — no disambiguation.
87
+ fen = '4r2k/8/8/8/4N3/8/8/1N2K3 w - - 0 1'
88
+ expect(PGN::Notation.san(pos(fen), 'b1', 'd2')).to eq('Nd2')
89
+ end
90
+ end
91
+
92
+ describe '.san check and checkmate suffixes' do
93
+ it 'appends + for a checking move' do
94
+ fen = '7k/8/8/8/8/8/6R1/6K1 w - - 0 1'
95
+ expect(PGN::Notation.san(pos(fen), 'g2', 'g8')).to eq('Rg8+')
96
+ end
97
+
98
+ it 'appends # for a checkmating move' do
99
+ fen = '6k1/5ppp/8/8/8/8/8/R6K w - - 0 1'
100
+ expect(PGN::Notation.san(pos(fen), 'a1', 'a8')).to eq('Ra8#')
101
+ end
102
+
103
+ it 'appends # to a promotion that gives checkmate' do
104
+ # White pawn b7 promotes with check on a king cornered on h8.
105
+ fen = '7k/1P6/8/8/8/8/8/6K1 w - - 0 1'
106
+ expect(PGN::Notation.san(pos(fen), 'b7', 'b8', 'q')).to eq('b8=Q+')
107
+ end
108
+
109
+ it 'does not append a suffix for a quiet move' do
110
+ fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
111
+ expect(PGN::Notation.san(pos(fen), 'd2', 'd4')).to eq('d4')
112
+ end
113
+ end
114
+
115
+ describe '.san_from_fen' do
116
+ it 'computes SAN directly from a FEN string' do
117
+ expect(PGN::Notation.san_from_fen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
118
+ 'e2', 'e4')).to eq('e4')
119
+ end
120
+
121
+ it 'computes SAN for black to move' do
122
+ fen = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1'
123
+ expect(PGN::Notation.san_from_fen(fen, 'd7', 'd5')).to eq('d5')
124
+ end
125
+ end
126
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgn2
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stacey Touset
@@ -168,6 +168,7 @@ files:
168
168
  - lib/pgn/lexer.rb
169
169
  - lib/pgn/move.rb
170
170
  - lib/pgn/move_calculator.rb
171
+ - lib/pgn/notation.rb
171
172
  - lib/pgn/parser.rb
172
173
  - lib/pgn/pgn_parser.rb
173
174
  - lib/pgn/pgn_parser.y
@@ -181,6 +182,7 @@ files:
181
182
  - spec/lexer_spec.rb
182
183
  - spec/move_calculator_spec.rb
183
184
  - spec/move_spec.rb
185
+ - spec/notation_spec.rb
184
186
  - spec/parser_explicit_spec.rb
185
187
  - spec/parser_spec.rb
186
188
  - spec/pgn_files/alternate_castling.pgn
@@ -234,6 +236,7 @@ test_files:
234
236
  - spec/lexer_spec.rb
235
237
  - spec/move_calculator_spec.rb
236
238
  - spec/move_spec.rb
239
+ - spec/notation_spec.rb
237
240
  - spec/parser_explicit_spec.rb
238
241
  - spec/parser_spec.rb
239
242
  - spec/pgn_files/alternate_castling.pgn