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 +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +40 -7
- data/TODO.md +38 -31
- data/bench/baseline_parse.txt +6 -6
- data/lib/pgn/board.rb +39 -10
- data/lib/pgn/lexer.rb +52 -13
- data/lib/pgn/move_calculator.rb +33 -19
- data/lib/pgn/notation.rb +474 -0
- data/lib/pgn/pgn_parser.rb +4 -2
- data/lib/pgn/pgn_parser.y +4 -2
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +1 -0
- data/spec/notation_spec.rb +126 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: da98f302d6785b75581d53fbe6dab4b7ccad9f5514f7ad278be12a540633d572
|
|
4
|
+
data.tar.gz: 9b22f32a2b44bd06cfd8d29c82f9b4af8c1c4687ed9650df33c4d5fdc76dd49e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 |
|
|
185
|
-
| Parse-only allocations (bytes) | 120370470 |
|
|
186
|
-
| Parse + replay allocations (objects) | 3778073 |
|
|
187
|
-
| Parse + replay allocations (bytes) | 249570048 |
|
|
188
|
-
| Parse-only throughput | 1461 ms/i |
|
|
189
|
-
| Parse + replay throughput | 1938 ms/i |
|
|
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 (
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
data/bench/baseline_parse.txt
CHANGED
|
@@ -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:
|
|
5
|
-
total_allocated bytes:
|
|
4
|
+
total_allocated objects: 288537
|
|
5
|
+
total_allocated bytes: 15640374
|
|
6
6
|
|
|
7
7
|
=== 2. Parse + replay allocations (500 games) ===
|
|
8
|
-
total_allocated objects:
|
|
9
|
-
total_allocated bytes:
|
|
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.
|
|
16
|
+
parse 500 games 4.926 (±20.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
|
|
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.
|
|
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.
|
|
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
|
|
121
|
+
return at_index(index_for(arg0, arg1)) unless arg1.nil?
|
|
123
122
|
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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 #{@
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
data/lib/pgn/move_calculator.rb
CHANGED
|
@@ -30,7 +30,7 @@ module PGN
|
|
|
30
30
|
# 0x88 single-step offsets for knight and king.
|
|
31
31
|
#
|
|
32
32
|
STEP = {
|
|
33
|
-
'k' => [
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
(
|
|
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
|
|
340
|
+
idx = board.index_for(file, rank)
|
|
327
341
|
return idx if board.at_index(idx) == king
|
|
328
342
|
end
|
|
329
343
|
end
|
data/lib/pgn/notation.rb
ADDED
|
@@ -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
|
data/lib/pgn/pgn_parser.rb
CHANGED
|
@@ -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
|
|
25
|
-
|
|
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
|
|
110
|
-
|
|
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
data/lib/pgn.rb
CHANGED
|
@@ -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.
|
|
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
|